Fix GUI instance counts and launcher tmp paths
This commit is contained in:
@@ -37,6 +37,7 @@ jobs:
|
|||||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||||
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
||||||
|
|
||||||
- name: Package executables
|
- name: Package executables
|
||||||
@@ -46,7 +47,7 @@ jobs:
|
|||||||
cp config/config.example.toml dist/config.example.toml
|
cp config/config.example.toml dist/config.example.toml
|
||||||
cp config/secrets.example.toml dist/secrets.example.toml
|
cp config/secrets.example.toml dist/secrets.example.toml
|
||||||
cp config/guests.example.txt dist/guests.example.txt
|
cp config/guests.example.txt dist/guests.example.txt
|
||||||
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrc_osc_gui.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -4,18 +4,39 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
|
var mode string
|
||||||
|
var noGUI bool
|
||||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||||
|
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||||
|
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if err := app.Run(configPath); err != nil {
|
if mode == "" && !noGUI {
|
||||||
|
launchGUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launchGUI() {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||||
|
if _, err := os.Stat(gui); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = exec.Command(gui).Start()
|
||||||
|
}
|
||||||
|
|||||||
14
cmd/vrc_osc_gui/main.go
Normal file
14
cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe")
|
||||||
|
log.Fatal(runNativeGUI())
|
||||||
|
}
|
||||||
216
cmd/vrc_osc_gui/tray_windows.go
Normal file
216
cmd/vrc_osc_gui/tray_windows.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wmUser = 0x0400
|
||||||
|
wmTray = wmUser + 1
|
||||||
|
wmCommand = 0x0111
|
||||||
|
wmDestroy = 0x0002
|
||||||
|
wmClose = 0x0010
|
||||||
|
wmRButtonUp = 0x0205
|
||||||
|
wmLButtonUp = 0x0202
|
||||||
|
wmCreate = 0x0001
|
||||||
|
wmQuit = 0x0012
|
||||||
|
niAdd = 0x00000000
|
||||||
|
niModify = 0x00000001
|
||||||
|
niDelete = 0x00000002
|
||||||
|
nifMessage = 0x00000001
|
||||||
|
nifIcon = 0x00000002
|
||||||
|
nifTip = 0x00000004
|
||||||
|
nifInfo = 0x00000010
|
||||||
|
nimPopup = 0x00000000
|
||||||
|
miString = 0x00000000
|
||||||
|
miSeparator = 0x00000800
|
||||||
|
miDefault = 0x00001000
|
||||||
|
swHide = 0
|
||||||
|
idOpen = 1001
|
||||||
|
idExit = 1002
|
||||||
|
maxTip = 128
|
||||||
|
maxClassName = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type trayApp struct {
|
||||||
|
uiURL string
|
||||||
|
open func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTray(uiURL string, open func()) *trayApp {
|
||||||
|
return &trayApp{uiURL: uiURL, open: open}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trayApp) Run() error {
|
||||||
|
return runTray(t.uiURL, t.open)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) {
|
||||||
|
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTray(uiURL string, open func()) error {
|
||||||
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
|
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
|
||||||
|
registerClass := user32.NewProc("RegisterClassW")
|
||||||
|
createWindowEx := user32.NewProc("CreateWindowExW")
|
||||||
|
defWindowProc := user32.NewProc("DefWindowProcW")
|
||||||
|
getMessage := user32.NewProc("GetMessageW")
|
||||||
|
translateMessage := user32.NewProc("TranslateMessage")
|
||||||
|
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||||
|
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||||
|
createPopupMenu := user32.NewProc("CreatePopupMenu")
|
||||||
|
appendMenu := user32.NewProc("AppendMenuW")
|
||||||
|
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||||
|
trackPopupMenu := user32.NewProc("TrackPopupMenu")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
loadIcon := user32.NewProc("LoadIconW")
|
||||||
|
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
|
||||||
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||||
|
|
||||||
|
type wndClassEx struct {
|
||||||
|
cbSize uint32
|
||||||
|
style uint32
|
||||||
|
lpfnWndProc uintptr
|
||||||
|
cbClsExtra int32
|
||||||
|
cbWndExtra int32
|
||||||
|
hInstance uintptr
|
||||||
|
hIcon uintptr
|
||||||
|
hCursor uintptr
|
||||||
|
hbrBackground uintptr
|
||||||
|
lpszMenuName *uint16
|
||||||
|
lpszClassName *uint16
|
||||||
|
hIconSm uintptr
|
||||||
|
}
|
||||||
|
type point struct{ X, Y int32 }
|
||||||
|
type msg struct {
|
||||||
|
hwnd uintptr
|
||||||
|
message uint32
|
||||||
|
wParam uintptr
|
||||||
|
lParam uintptr
|
||||||
|
time uint32
|
||||||
|
pt point
|
||||||
|
}
|
||||||
|
type notifyIconData struct {
|
||||||
|
cbSize uint32
|
||||||
|
hWnd uintptr
|
||||||
|
uID uint32
|
||||||
|
uFlags uint32
|
||||||
|
uCallbackMessage uint32
|
||||||
|
hIcon uintptr
|
||||||
|
szTip [maxTip]uint16
|
||||||
|
dwState uint32
|
||||||
|
dwStateMask uint32
|
||||||
|
szInfo [256]uint16
|
||||||
|
uTimeoutVersion uint32
|
||||||
|
szInfoTitle [64]uint16
|
||||||
|
dwInfoFlags uint32
|
||||||
|
guidItem [16]byte
|
||||||
|
hBalloonIcon uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
|
||||||
|
windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray")
|
||||||
|
icon, _, _ := loadIcon.Call(0, 32512) // IDI_APPLICATION
|
||||||
|
|
||||||
|
var hwnd uintptr
|
||||||
|
var menu uintptr
|
||||||
|
var nid notifyIconData
|
||||||
|
|
||||||
|
wndProc := syscall.NewCallback(func(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
|
||||||
|
switch msg {
|
||||||
|
case wmCreate:
|
||||||
|
return 0
|
||||||
|
case wmTray:
|
||||||
|
switch lParam {
|
||||||
|
case wmLButtonUp:
|
||||||
|
if open != nil {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
case wmRButtonUp:
|
||||||
|
if menu == 0 {
|
||||||
|
m, _, _ := createPopupMenu.Call()
|
||||||
|
menu = m
|
||||||
|
openText, _ := syscall.UTF16PtrFromString("Open UI")
|
||||||
|
exitText, _ := syscall.UTF16PtrFromString("Exit")
|
||||||
|
appendMenu.Call(menu, miString|miDefault, idOpen, uintptr(unsafe.Pointer(openText)))
|
||||||
|
appendMenu.Call(menu, miString, idExit, uintptr(unsafe.Pointer(exitText)))
|
||||||
|
}
|
||||||
|
setForegroundWindow.Call(hwnd)
|
||||||
|
var p point
|
||||||
|
user32.NewProc("GetCursorPos").Call(uintptr(unsafe.Pointer(&p)))
|
||||||
|
trackPopupMenu.Call(menu, nimPopup, uintptr(p.X), uintptr(p.Y), 0, hwnd, 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case wmCommand:
|
||||||
|
switch uint16(wParam & 0xffff) {
|
||||||
|
case idOpen:
|
||||||
|
if open != nil {
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
case idExit:
|
||||||
|
user32.NewProc("DestroyWindow").Call(hwnd)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case wmDestroy:
|
||||||
|
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||||
|
nid.hWnd = hwnd
|
||||||
|
nid.uID = 1
|
||||||
|
shellNotifyIcon.Call(niDelete, uintptr(unsafe.Pointer(&nid)))
|
||||||
|
postQuitMessage.Call(0)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ret, _, _ := defWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
|
||||||
|
hInstance, _, _ := getModuleHandle.Call(0)
|
||||||
|
_, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClassEx{
|
||||||
|
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||||
|
lpfnWndProc: wndProc,
|
||||||
|
hInstance: hInstance,
|
||||||
|
hIcon: icon,
|
||||||
|
lpszClassName: className,
|
||||||
|
})))
|
||||||
|
if regErr != nil {
|
||||||
|
return fmt.Errorf("RegisterClassW failed: %v", regErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
hwnd, _, err := createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowTitle)), 0, 0, 0, 0, 0, 0, 0, hInstance, 0)
|
||||||
|
if hwnd == 0 {
|
||||||
|
return fmt.Errorf("CreateWindowExW failed: %v", err)
|
||||||
|
}
|
||||||
|
showWindow.Call(hwnd, swHide)
|
||||||
|
|
||||||
|
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||||
|
nid.hWnd = hwnd
|
||||||
|
nid.uID = 1
|
||||||
|
nid.uFlags = nifMessage | nifIcon | nifTip
|
||||||
|
nid.uCallbackMessage = wmTray
|
||||||
|
nid.hIcon = icon
|
||||||
|
tip := syscall.StringToUTF16("VRC OSC")
|
||||||
|
copy(nid.szTip[:], tip)
|
||||||
|
if ok, _, _ := shellNotifyIcon.Call(niAdd, uintptr(unsafe.Pointer(&nid))); ok == 0 {
|
||||||
|
return fmt.Errorf("Shell_NotifyIconW add failed")
|
||||||
|
}
|
||||||
|
log.Printf("tray started")
|
||||||
|
|
||||||
|
var m msg
|
||||||
|
for {
|
||||||
|
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||||
|
if int32(r) <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
531
cmd/vrc_osc_gui/window_windows.go
Normal file
531
cmd/vrc_osc_gui/window_windows.go
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsOverlappedWindow = 0x00CF0000
|
||||||
|
wsVisible = 0x10000000
|
||||||
|
wsChild = 0x40000000
|
||||||
|
wsBorder = 0x00800000
|
||||||
|
esMultiline = 0x0004
|
||||||
|
esReadonly = 0x0800
|
||||||
|
esAutovscroll = 0x0040
|
||||||
|
esAutohscroll = 0x0080
|
||||||
|
wmCreateGUI = 0x0001
|
||||||
|
wmDestroyGUI = 0x0002
|
||||||
|
wmTimerGUI = 0x0113
|
||||||
|
wmCommandGUI = 0x0111
|
||||||
|
idStatus = 2001
|
||||||
|
idLog = 2002
|
||||||
|
timerRefresh = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var joinLeaveLinePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
||||||
|
var runtimeTimePattern = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]`)
|
||||||
|
|
||||||
|
type guiApp struct {
|
||||||
|
hwnd uintptr
|
||||||
|
leftHwnd uintptr
|
||||||
|
rightHwnd uintptr
|
||||||
|
tabJoin uintptr
|
||||||
|
tabDiscord uintptr
|
||||||
|
tabTranslate uintptr
|
||||||
|
activeTab string
|
||||||
|
lastInstanceCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type userState struct {
|
||||||
|
Name string
|
||||||
|
Present bool
|
||||||
|
LastJoin time.Time
|
||||||
|
LastLeave time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "runtime"
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exe), "runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
func guiLog(text string) {
|
||||||
|
_ = app.AppendRuntimeLog("GUI", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNativeGUI() error {
|
||||||
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
registerClass := user32.NewProc("RegisterClassW")
|
||||||
|
createWindowEx := user32.NewProc("CreateWindowExW")
|
||||||
|
defWindowProc := user32.NewProc("DefWindowProcW")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
updateWindow := user32.NewProc("UpdateWindow")
|
||||||
|
getMessage := user32.NewProc("GetMessageW")
|
||||||
|
translateMessage := user32.NewProc("TranslateMessage")
|
||||||
|
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||||
|
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||||
|
setTimer := user32.NewProc("SetTimer")
|
||||||
|
loadCursor := user32.NewProc("LoadCursorW")
|
||||||
|
setWindowText := user32.NewProc("SetWindowTextW")
|
||||||
|
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||||
|
loadIcon := user32.NewProc("LoadIconW")
|
||||||
|
|
||||||
|
type wndClass struct {
|
||||||
|
style uint32
|
||||||
|
lpfnWndProc uintptr
|
||||||
|
cbClsExtra int32
|
||||||
|
cbWndExtra int32
|
||||||
|
hInstance uintptr
|
||||||
|
hIcon uintptr
|
||||||
|
hCursor uintptr
|
||||||
|
hbrBackground uintptr
|
||||||
|
lpszMenuName *uint16
|
||||||
|
lpszClassName *uint16
|
||||||
|
}
|
||||||
|
type msg struct {
|
||||||
|
hwnd uintptr
|
||||||
|
message uint32
|
||||||
|
wParam uintptr
|
||||||
|
lParam uintptr
|
||||||
|
time uint32
|
||||||
|
pt struct{ X, Y int32 }
|
||||||
|
}
|
||||||
|
|
||||||
|
hInstance, _, _ := getModuleHandle.Call(0)
|
||||||
|
className, _ := syscall.UTF16PtrFromString("VRC_OSC_NATIVE_GUI")
|
||||||
|
title, _ := syscall.UTF16PtrFromString("VRC OSC")
|
||||||
|
cursor, _, _ := loadCursor.Call(0, 32512)
|
||||||
|
icon, _, _ := loadIcon.Call(0, 32512)
|
||||||
|
guiLog("stage=prepare window")
|
||||||
|
|
||||||
|
var app guiApp
|
||||||
|
wndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
|
||||||
|
switch message {
|
||||||
|
case wmCreateGUI:
|
||||||
|
buttonClass, _ := syscall.UTF16PtrFromString("BUTTON")
|
||||||
|
leftClass, _ := syscall.UTF16PtrFromString("EDIT")
|
||||||
|
rightClass, _ := syscall.UTF16PtrFromString("EDIT")
|
||||||
|
leftTitle, _ := syscall.UTF16PtrFromString("")
|
||||||
|
rightTitle, _ := syscall.UTF16PtrFromString("")
|
||||||
|
joinTitle, _ := syscall.UTF16PtrFromString("Join Log")
|
||||||
|
discordTitle, _ := syscall.UTF16PtrFromString("Discord")
|
||||||
|
translateTitle, _ := syscall.UTF16PtrFromString("Translate")
|
||||||
|
app.tabJoin, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(joinTitle)), wsVisible|wsChild, 370, 20, 90, 28, hwnd, 3001, hInstance, 0)
|
||||||
|
app.tabDiscord, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(discordTitle)), wsVisible|wsChild, 470, 20, 90, 28, hwnd, 3002, hInstance, 0)
|
||||||
|
app.tabTranslate, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(translateTitle)), wsVisible|wsChild, 570, 20, 90, 28, hwnd, 3003, hInstance, 0)
|
||||||
|
app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(leftClass)), uintptr(unsafe.Pointer(leftTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 20, 20, 330, 300, hwnd, idStatus, hInstance, 0)
|
||||||
|
app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(rightClass)), uintptr(unsafe.Pointer(rightTitle)), wsVisible|wsChild|wsBorder|esMultiline|esReadonly|esAutovscroll|esAutohscroll, 370, 60, 330, 260, hwnd, idLog, hInstance, 0)
|
||||||
|
app.activeTab = "join"
|
||||||
|
setTimer.Call(hwnd, timerRefresh, 2000, 0)
|
||||||
|
refreshGUI(setWindowText, &app)
|
||||||
|
return 0
|
||||||
|
case wmCommandGUI:
|
||||||
|
switch uint16(wParam & 0xffff) {
|
||||||
|
case 3001:
|
||||||
|
app.activeTab = "join"
|
||||||
|
case 3002:
|
||||||
|
app.activeTab = "discord"
|
||||||
|
case 3003:
|
||||||
|
app.activeTab = "translate"
|
||||||
|
}
|
||||||
|
refreshGUI(setWindowText, &app)
|
||||||
|
return 0
|
||||||
|
case wmTimerGUI:
|
||||||
|
refreshGUI(setWindowText, &app)
|
||||||
|
return 0
|
||||||
|
case wmDestroyGUI:
|
||||||
|
postQuitMessage.Call(0)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ret, _, _ := defWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
|
||||||
|
guiLog("stage=register class")
|
||||||
|
atom, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClass{
|
||||||
|
style: 0,
|
||||||
|
lpfnWndProc: wndProc,
|
||||||
|
hInstance: hInstance,
|
||||||
|
hIcon: icon,
|
||||||
|
hCursor: cursor,
|
||||||
|
hbrBackground: 6,
|
||||||
|
lpszClassName: className,
|
||||||
|
})))
|
||||||
|
if atom == 0 {
|
||||||
|
guiLog(fmt.Sprintf("stage=register class failed err=%v", regErr))
|
||||||
|
return fmt.Errorf("register class: %v", regErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
guiLog("stage=create window")
|
||||||
|
app.hwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), wsOverlappedWindow, 200, 120, 760, 380, 0, 0, hInstance, 0)
|
||||||
|
if app.hwnd == 0 {
|
||||||
|
guiLog("stage=create window failed")
|
||||||
|
return fmt.Errorf("create window failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
guiLog("stage=show window")
|
||||||
|
showWindow.Call(app.hwnd, 5)
|
||||||
|
updateWindow.Call(app.hwnd)
|
||||||
|
guiLog("stage=message loop")
|
||||||
|
|
||||||
|
var m msg
|
||||||
|
for {
|
||||||
|
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||||
|
if int32(r) <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp) {
|
||||||
|
current, instanceCount := currentUsersFromJoinLeave()
|
||||||
|
if instanceCount == 0 && app.lastInstanceCount > 0 {
|
||||||
|
instanceCount = app.lastInstanceCount
|
||||||
|
}
|
||||||
|
if instanceCount > 0 {
|
||||||
|
app.lastInstanceCount = instanceCount
|
||||||
|
}
|
||||||
|
if app.leftHwnd != 0 {
|
||||||
|
t, _ := syscall.UTF16PtrFromString(formatGuestPane("Join Log", current))
|
||||||
|
setWindowText.Call(app.leftHwnd, uintptr(unsafe.Pointer(t)))
|
||||||
|
}
|
||||||
|
if app.rightHwnd != 0 {
|
||||||
|
t, _ := syscall.UTF16PtrFromString(formatRightPane(app.activeTab, readRuntimeSnapshot(), currentWorldLabel(), instanceCount))
|
||||||
|
setWindowText.Call(app.rightHwnd, uintptr(unsafe.Pointer(t)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUsersFromJoinLeave() ([]userState, int) {
|
||||||
|
since := currentWorldStartTime()
|
||||||
|
users := map[string]*userState{}
|
||||||
|
lastLoggedCount := 0
|
||||||
|
for _, s := range readGuestSnapshot() {
|
||||||
|
if !s.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := s
|
||||||
|
users[s.Name] = &userState{
|
||||||
|
Name: s.Name,
|
||||||
|
Present: true,
|
||||||
|
LastJoin: cp.LastJoin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := filepath.Join(runtimeDir(), "join_leave.log")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
out := make([]userState, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, *u)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Present != out[j].Present {
|
||||||
|
return out[i].Present
|
||||||
|
}
|
||||||
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
})
|
||||||
|
return out, countPresent(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c := parseJoinLeaveCount(line); c > 0 {
|
||||||
|
lastLoggedCount = c
|
||||||
|
}
|
||||||
|
at, kind, name := parseJoinLeaveLine(line)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !since.IsZero() && at.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := users[name]
|
||||||
|
if u == nil {
|
||||||
|
u = &userState{Name: name}
|
||||||
|
users[name] = u
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "join":
|
||||||
|
u.Present = true
|
||||||
|
if at.After(u.LastJoin) {
|
||||||
|
u.LastJoin = at
|
||||||
|
}
|
||||||
|
case "leave":
|
||||||
|
u.Present = false
|
||||||
|
if at.After(u.LastLeave) {
|
||||||
|
u.LastLeave = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep any snapshot-marked present users that the join/leave delta can't rebuild.
|
||||||
|
for _, snap := range readGuestSnapshot() {
|
||||||
|
if !snap.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := users[snap.Name]
|
||||||
|
if u == nil {
|
||||||
|
users[snap.Name] = &userState{
|
||||||
|
Name: snap.Name,
|
||||||
|
Present: true,
|
||||||
|
LastJoin: snap.LastJoin,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !u.Present {
|
||||||
|
u.Present = true
|
||||||
|
if u.LastJoin.IsZero() {
|
||||||
|
u.LastJoin = snap.LastJoin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]userState, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, *u)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Present != out[j].Present {
|
||||||
|
return out[i].Present
|
||||||
|
}
|
||||||
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
})
|
||||||
|
present := countPresent(out)
|
||||||
|
if lastLoggedCount > present {
|
||||||
|
present = lastLoggedCount
|
||||||
|
}
|
||||||
|
return out, present
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGuestSnapshot() []app.GuestStatus {
|
||||||
|
p := filepath.Join(runtimeDir(), "guest_snapshot.json")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []app.GuestStatus
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGuestPane(title string, items []userState) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(title)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(strings.Repeat("=", len(title)))
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
if len(items) == 0 {
|
||||||
|
b.WriteString("Current in room: 0")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString("(none)")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
b.WriteString("Current in room: ")
|
||||||
|
b.WriteString(strconv.Itoa(countPresent(items)))
|
||||||
|
b.WriteString("\r\n\r\n")
|
||||||
|
for _, item := range items {
|
||||||
|
b.WriteString("・ ")
|
||||||
|
b.WriteString(item.Name)
|
||||||
|
if item.Present {
|
||||||
|
b.WriteString(" [present]")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" [gray]")
|
||||||
|
if !item.LastLeave.IsZero() {
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(timeAgo(item.LastLeave))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
switch tab {
|
||||||
|
case "discord":
|
||||||
|
b.WriteString("Discord")
|
||||||
|
case "translate":
|
||||||
|
b.WriteString("Translate")
|
||||||
|
default:
|
||||||
|
b.WriteString("Current World")
|
||||||
|
}
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
titleLine := "Current World"
|
||||||
|
switch tab {
|
||||||
|
case "discord":
|
||||||
|
titleLine = "Discord"
|
||||||
|
case "translate":
|
||||||
|
titleLine = "Translate"
|
||||||
|
}
|
||||||
|
b.WriteString(strings.Repeat("=", len(titleLine)))
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
discordMuted, _ := state["discord_muted"].(bool)
|
||||||
|
ocr, _ := state["ocr"].(string)
|
||||||
|
translate, _ := state["translate"].(string)
|
||||||
|
switch tab {
|
||||||
|
case "discord":
|
||||||
|
b.WriteString("Muted: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%v", discordMuted))
|
||||||
|
b.WriteString("\r\nWorld: ")
|
||||||
|
b.WriteString(worldLabel)
|
||||||
|
b.WriteString("\r\nUsers: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%d", currentUsers))
|
||||||
|
case "translate":
|
||||||
|
b.WriteString("Translate: ")
|
||||||
|
b.WriteString(translate)
|
||||||
|
b.WriteString("\r\nOCR: ")
|
||||||
|
b.WriteString(ocr)
|
||||||
|
b.WriteString("\r\nWorld: ")
|
||||||
|
b.WriteString(worldLabel)
|
||||||
|
default:
|
||||||
|
b.WriteString("World: ")
|
||||||
|
b.WriteString(worldLabel)
|
||||||
|
b.WriteString("\r\nUsers: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%d", currentUsers))
|
||||||
|
b.WriteString("\r\nDiscord muted: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%v", discordMuted))
|
||||||
|
b.WriteString("\r\nOCR: ")
|
||||||
|
b.WriteString(ocr)
|
||||||
|
b.WriteString("\r\nTranslate: ")
|
||||||
|
b.WriteString(translate)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentWorldLabel() string {
|
||||||
|
p := filepath.Join(runtimeDir(), "runtime.log")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(b), "\n")
|
||||||
|
var latest string
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
if !strings.Contains(lines[i], "VRC WORLD") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 < len(lines) {
|
||||||
|
latest = strings.TrimSpace(lines[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRuntimeSnapshot() map[string]any {
|
||||||
|
p := filepath.Join(runtimeDir(), "state.json")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(b, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentWorldStartTime() time.Time {
|
||||||
|
p := filepath.Join(runtimeDir(), "runtime.log")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
var latest time.Time
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" || !strings.Contains(line, "VRC WORLD") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at, ok := parseRuntimeTime(line); ok && at.After(latest) {
|
||||||
|
latest = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJoinLeaveLine(line string) (time.Time, string, string) {
|
||||||
|
m := joinLeaveLinePattern.FindStringSubmatch(line)
|
||||||
|
if len(m) != 4 {
|
||||||
|
return time.Time{}, "", ""
|
||||||
|
}
|
||||||
|
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, "", ""
|
||||||
|
}
|
||||||
|
return at, m[2], strings.TrimSpace(m[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJoinLeaveCount(line string) int {
|
||||||
|
m := regexp.MustCompile(`\((\d+)\)$`).FindStringSubmatch(line)
|
||||||
|
if len(m) != 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeTime(line string) (time.Time, bool) {
|
||||||
|
m := runtimeTimePattern.FindStringSubmatch(line)
|
||||||
|
if len(m) != 2 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
at, err := time.Parse("2006-01-02 15:04:05", m[1])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return at, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPresent(items []userState) int {
|
||||||
|
n := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Present {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeAgo(at time.Time) string {
|
||||||
|
if at.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := time.Since(at)
|
||||||
|
if d < time.Minute {
|
||||||
|
return "0分前"
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%d分前", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
if d < 24*time.Hour {
|
||||||
|
return fmt.Sprintf("%d時間前", int(d.Hours()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d日前", int(d.Hours()/24))
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"vrc_osc_go/internal/buildinfo"
|
"vrc_osc_go/internal/buildinfo"
|
||||||
"vrc_osc_go/internal/common"
|
"vrc_osc_go/internal/common"
|
||||||
@@ -31,12 +33,18 @@ 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)
|
||||||
}
|
}
|
||||||
exe := filepath.Join(common.RootDir(), "vrc_osc.exe")
|
base := runtimeBaseDir()
|
||||||
|
exe := filepath.Join(base, "vrc_osc.exe")
|
||||||
|
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
||||||
if _, err := os.Stat(exe); err != nil {
|
if _, err := os.Stat(exe); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
if err := update.Launch(exe, os.Args[1:]); err != nil {
|
if _, err := os.Stat(gui); err == nil {
|
||||||
|
_ = exec.Command(gui).Start()
|
||||||
|
}
|
||||||
|
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
||||||
|
if err := update.Launch(exe, args); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -49,8 +57,19 @@ func showError(title, message string) {
|
|||||||
const mbIconError = 0x00000010
|
const mbIconError = 0x00000010
|
||||||
_, _, _ = msgBox.Call(
|
_, _, _ = msgBox.Call(
|
||||||
0,
|
0,
|
||||||
uintptr(syscall.StringToUTF16Ptr(message)),
|
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
|
||||||
uintptr(syscall.StringToUTF16Ptr(title)),
|
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
|
||||||
uintptr(mbOK|mbIconError),
|
uintptr(mbOK|mbIconError),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeBaseDir() string {
|
||||||
|
root := common.RootDir()
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "vrc_osc.exe")); err == nil {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "tmp", "vrc_osc.exe")); err == nil {
|
||||||
|
return filepath.Join(root, "tmp")
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|||||||
33
cmd/vrwt_tool/main.go
Normal file
33
cmd/vrwt_tool/main.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/consenttool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cfgPath string
|
||||||
|
var mode string
|
||||||
|
|
||||||
|
flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config")
|
||||||
|
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if mode == "" && flag.NArg() > 0 {
|
||||||
|
mode = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := consenttool.Run(cfgPath, mode); err != nil {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
log.Fatalf("vrwt_tool: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,75 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"vrc_osc_go/internal/config"
|
"vrc_osc_go/internal/config"
|
||||||
|
"vrc_osc_go/internal/consenttool"
|
||||||
"vrc_osc_go/internal/osc"
|
"vrc_osc_go/internal/osc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(configPath string) error {
|
type discordMuteState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
muted bool
|
||||||
|
buttonPressed bool
|
||||||
|
lastAction time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(configPath string, mode string) error {
|
||||||
|
if mode != "" {
|
||||||
|
return consenttool.Run(configPath, mode)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
||||||
|
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||||
|
|
||||||
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||||
|
discordState := &discordMuteState{muted: true}
|
||||||
|
SetDiscordMuted(true)
|
||||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||||
return fmt.Errorf("discord mute not implemented yet")
|
log.Printf("received DiscordSend args=%+v", args)
|
||||||
|
if err := handleDiscordSend(discordState, args); err != nil {
|
||||||
|
log.Printf("discord mute failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
SetDiscordMuted(discordState.muted)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
||||||
|
log.Printf("received ocrEnabled args=%+v", args)
|
||||||
|
if err := runPythonOcrFromScreen(); err != nil {
|
||||||
|
log.Printf("ocr failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() { errCh <- server.Serve() }()
|
go func() {
|
||||||
|
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||||
|
errCh <- server.Serve()
|
||||||
|
}()
|
||||||
|
go watchVrchatLog()
|
||||||
|
startSelfMonitor(cfg, discordState)
|
||||||
|
|
||||||
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)
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
|
log.Printf("osc server stopped: %v", err)
|
||||||
return err
|
return err
|
||||||
case <-sigCh:
|
case <-sigCh:
|
||||||
|
log.Printf("shutdown signal received")
|
||||||
server.Close()
|
server.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
internal/app/discord_mute.go
Normal file
89
internal/app/discord_mute.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/osc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const discordDebounce = 300 * time.Millisecond
|
||||||
|
|
||||||
|
func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("OSC args is empty")
|
||||||
|
}
|
||||||
|
isPressed, err := toBool(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPressed && !state.buttonPressed {
|
||||||
|
state.buttonPressed = true
|
||||||
|
if state.muted {
|
||||||
|
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
||||||
|
if err := pressDiscordMuteHotkey(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.muted = false
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
|
state.lastAction = now
|
||||||
|
} else {
|
||||||
|
log.Printf("INFO already unmuted")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBool(v osc.Value) (bool, error) {
|
||||||
|
switch v.Type {
|
||||||
|
case 'T':
|
||||||
|
return true, nil
|
||||||
|
case 'F':
|
||||||
|
return false, nil
|
||||||
|
case 'i':
|
||||||
|
return v.Int != 0, nil
|
||||||
|
case 'f':
|
||||||
|
return v.Float != 0, nil
|
||||||
|
case 's':
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v.Str)) {
|
||||||
|
case "true", "1", "on", "yes":
|
||||||
|
return true, nil
|
||||||
|
case "false", "0", "off", "no":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported OSC value: %q", v.Str)
|
||||||
|
}
|
||||||
|
case 0:
|
||||||
|
return false, fmt.Errorf("unsupported OSC value")
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported OSC type: %q", v.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
internal/app/guest_status.go
Normal file
136
internal/app/guest_status.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GuestStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
LastJoin time.Time `json:"last_join"`
|
||||||
|
LastLeave time.Time `json:"last_leave"`
|
||||||
|
AbsentFor string `json:"absent_for"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
current string
|
||||||
|
items map[string]*GuestStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGuestTracker(names []string) *GuestTracker {
|
||||||
|
items := make(map[string]*GuestStatus, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
items[name] = &GuestStatus{Name: name}
|
||||||
|
}
|
||||||
|
return &GuestTracker{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) SetCurrentInstance(label string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.current = label
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) CurrentInstance() string {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) MarkJoin(name string, at time.Time) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
s := t.ensure(name)
|
||||||
|
s.Present = true
|
||||||
|
s.LastJoin = at
|
||||||
|
s.AbsentFor = ""
|
||||||
|
_ = t.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) MarkLeave(name string, at time.Time) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
s := t.ensure(name)
|
||||||
|
s.Present = false
|
||||||
|
s.LastLeave = at
|
||||||
|
if !s.LastJoin.IsZero() {
|
||||||
|
s.AbsentFor = humanSince(at)
|
||||||
|
}
|
||||||
|
_ = t.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
out := make([]GuestStatus, 0, len(t.items))
|
||||||
|
for _, s := range t.items {
|
||||||
|
cp := *s
|
||||||
|
if !cp.Present && !cp.LastLeave.IsZero() {
|
||||||
|
cp.AbsentFor = humanSince(cp.LastLeave, now)
|
||||||
|
}
|
||||||
|
out = append(out, cp)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) persistLocked() error {
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot := make([]GuestStatus, 0, len(t.items))
|
||||||
|
for _, s := range t.items {
|
||||||
|
cp := *s
|
||||||
|
snapshot = append(snapshot, cp)
|
||||||
|
}
|
||||||
|
f, err := os.Create(filepath.Join(dir, "guest_snapshot.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GuestTracker) ensure(name string) *GuestStatus {
|
||||||
|
if s, ok := t.items[name]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s := &GuestStatus{Name: name}
|
||||||
|
t.items[name] = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanSince(at time.Time, now ...time.Time) string {
|
||||||
|
ref := time.Now()
|
||||||
|
if len(now) > 0 {
|
||||||
|
ref = now[0]
|
||||||
|
}
|
||||||
|
if at.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := ref.Sub(at)
|
||||||
|
if d < time.Minute {
|
||||||
|
return "1分未満"
|
||||||
|
}
|
||||||
|
m := int(d.Minutes())
|
||||||
|
if m < 60 {
|
||||||
|
return fmt.Sprintf("%d分前", m)
|
||||||
|
}
|
||||||
|
h := m / 60
|
||||||
|
if h < 24 {
|
||||||
|
return fmt.Sprintf("%d時間前", h)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d日前", h/24)
|
||||||
|
}
|
||||||
30
internal/app/ocr.go
Normal file
30
internal/app/ocr.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
`
|
||||||
|
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("翻訳: 未実装")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ocr failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
internal/app/runtime.go
Normal file
52
internal/app/runtime.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appendRuntimeLog(title, text string) error {
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "runtime.log")
|
||||||
|
body := text
|
||||||
|
if body == "" {
|
||||||
|
body = "(empty)"
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = fmt.Fprintf(f, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendRuntimeLog(title, text string) error {
|
||||||
|
return appendRuntimeLog(title, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendJoinLeaveLog(text string) error {
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "join_leave.log")
|
||||||
|
body := text
|
||||||
|
if body == "" {
|
||||||
|
body = "(empty)"
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
69
internal/app/self_monitor.go
Normal file
69
internal/app/self_monitor.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/config"
|
||||||
|
"vrc_osc_go/internal/osc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
|
selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
||||||
|
if cfg.VrcLog.SelfName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
lastState := ""
|
||||||
|
for {
|
||||||
|
path, err := findLatestVrchatLog()
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName)
|
||||||
|
if nextState != "" && nextState != lastState {
|
||||||
|
if nextState == "joined" {
|
||||||
|
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
||||||
|
log.Printf("self monitor discord mute failed: %v", err)
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastState = nextState
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLatestSelfLogState(text, selfName string) string {
|
||||||
|
if selfName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
state := ""
|
||||||
|
for _, rawLine := range strings.Split(text, "\n") {
|
||||||
|
if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||||
|
state = "joined"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
|
||||||
|
state = "left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
89
internal/app/state.go
Normal file
89
internal/app/state.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
DiscordMuted bool
|
||||||
|
LastOCRText string
|
||||||
|
LastTranslate string
|
||||||
|
CurrentWorld string
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeState = &RuntimeState{}
|
||||||
|
var guestTracker *GuestTracker
|
||||||
|
|
||||||
|
func SetGuestTracker(t *GuestTracker) { guestTracker = t }
|
||||||
|
func InitGuestTracker(t *GuestTracker) {
|
||||||
|
guestTracker = t
|
||||||
|
if guestTracker != nil {
|
||||||
|
guestTracker.mu.Lock()
|
||||||
|
_ = guestTracker.persistLocked()
|
||||||
|
guestTracker.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func GetGuestTracker() *GuestTracker { return guestTracker }
|
||||||
|
|
||||||
|
func SetDiscordMuted(v bool) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.DiscordMuted = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetOCRText(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.LastOCRText = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetTranslateText(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.LastTranslate = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetCurrentWorld(v string) {
|
||||||
|
runtimeState.mu.Lock()
|
||||||
|
runtimeState.CurrentWorld = v
|
||||||
|
runtimeState.mu.Unlock()
|
||||||
|
_ = persistRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SnapshotRuntime() RuntimeState {
|
||||||
|
runtimeState.mu.RLock()
|
||||||
|
defer runtimeState.mu.RUnlock()
|
||||||
|
return *runtimeState
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistRuntimeState() error {
|
||||||
|
s := SnapshotRuntime()
|
||||||
|
dir := filepath.Join(common.RootDir(), "runtime")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(filepath.Join(dir, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(map[string]any{
|
||||||
|
"discord_muted": s.DiscordMuted,
|
||||||
|
"ocr": s.LastOCRText,
|
||||||
|
"translate": s.LastTranslate,
|
||||||
|
"world": s.CurrentWorld,
|
||||||
|
"updated_at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
345
internal/app/vrc_log.go
Normal file
345
internal/app/vrc_log.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
|
leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
|
||||||
|
worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`)
|
||||||
|
instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`)
|
||||||
|
worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`)
|
||||||
|
worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`)
|
||||||
|
worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`)
|
||||||
|
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type vrcLogState struct {
|
||||||
|
location string
|
||||||
|
worldID string
|
||||||
|
instanceID string
|
||||||
|
worldName string
|
||||||
|
pendingWorldName string
|
||||||
|
presentSet map[string]struct{}
|
||||||
|
initialized bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVrchatLogDir() (string, error) {
|
||||||
|
userProfile := os.Getenv("USERPROFILE")
|
||||||
|
if userProfile == "" {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLatestVrchatLog() (string, error) {
|
||||||
|
dir, err := getVrchatLogDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest string
|
||||||
|
var latestMod time.Time
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().After(latestMod) {
|
||||||
|
latestMod = info.ModTime()
|
||||||
|
latest = filepath.Join(dir, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latest == "" {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchVrchatLog() {
|
||||||
|
lastPath := ""
|
||||||
|
lastSize := int64(0)
|
||||||
|
state := &vrcLogState{
|
||||||
|
presentSet: map[string]struct{}{},
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
path, err := findLatestVrchatLog()
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path != lastPath {
|
||||||
|
log.Printf("vrchat log watching %s", path)
|
||||||
|
lastPath = path
|
||||||
|
lastSize = 0
|
||||||
|
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||||
|
log.Printf("vrchat log initial scan failed: %v", err)
|
||||||
|
}
|
||||||
|
if info2, err := os.Stat(path); err == nil {
|
||||||
|
lastSize = info2.Size()
|
||||||
|
} else {
|
||||||
|
lastSize = info.Size()
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() < lastSize {
|
||||||
|
lastSize = 0
|
||||||
|
}
|
||||||
|
if info.Size() > lastSize {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = f.Seek(lastSize, 0)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
||||||
|
log.Printf("append runtime log failed: %v", err)
|
||||||
|
}
|
||||||
|
if changed, worldLabel := updateWorldState(state, line); changed {
|
||||||
|
log.Printf("world changed: %s", worldLabel)
|
||||||
|
state.presentSet = map[string]struct{}{}
|
||||||
|
SetCurrentWorld(worldLabel)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.SetCurrentInstance(worldLabel)
|
||||||
|
}
|
||||||
|
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||||
|
}
|
||||||
|
at := extractLineTime(line)
|
||||||
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
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)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
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)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastSize = info.Size()
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
state.presentSet = map[string]struct{}{}
|
||||||
|
state.location = ""
|
||||||
|
state.worldID = ""
|
||||||
|
state.instanceID = ""
|
||||||
|
state.worldName = ""
|
||||||
|
state.pendingWorldName = ""
|
||||||
|
state.initialized = false
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if changed, _ := updateWorldState(state, line); changed {
|
||||||
|
state.presentSet = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
state.presentSet[name] = struct{}{}
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkJoin(name, extractLineTime(line))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
delete(state.presentSet, name)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkLeave(name, extractLineTime(line))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.initialized = true
|
||||||
|
if state.worldName != "" {
|
||||||
|
log.Printf("world changed: %s", state.worldName)
|
||||||
|
SetCurrentWorld(state.worldName)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.SetCurrentInstance(state.worldName)
|
||||||
|
}
|
||||||
|
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||||
|
worldID := ""
|
||||||
|
instanceID := ""
|
||||||
|
worldName := ""
|
||||||
|
roomTitle := ""
|
||||||
|
|
||||||
|
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
roomTitle = strings.TrimSpace(m[1])
|
||||||
|
state.pendingWorldName = roomTitle
|
||||||
|
}
|
||||||
|
if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 {
|
||||||
|
worldID = strings.TrimSpace(m[1])
|
||||||
|
instanceID = strings.TrimSpace(m[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
worldID = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
instanceID = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
|
worldName = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" {
|
||||||
|
if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 {
|
||||||
|
worldID = worldMatch[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLocation := ""
|
||||||
|
if worldID != "" {
|
||||||
|
nextLocation = worldID
|
||||||
|
if instanceID != "" {
|
||||||
|
nextLocation = worldID + ":" + instanceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextLocation != "" && nextLocation != state.location {
|
||||||
|
state.location = nextLocation
|
||||||
|
state.worldID = worldID
|
||||||
|
state.instanceID = instanceID
|
||||||
|
if worldName != "" {
|
||||||
|
state.worldName = worldName
|
||||||
|
} else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||||
|
state.worldName = roomTitle
|
||||||
|
}
|
||||||
|
label := state.worldName
|
||||||
|
if label == "" {
|
||||||
|
label = state.worldID
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
label = nextLocation
|
||||||
|
}
|
||||||
|
if state.initialized {
|
||||||
|
log.Printf("world changed: %s", label)
|
||||||
|
_ = appendRuntimeLog("VRC WORLD", label)
|
||||||
|
}
|
||||||
|
return true, label
|
||||||
|
}
|
||||||
|
|
||||||
|
if worldName != "" {
|
||||||
|
state.worldName = worldName
|
||||||
|
state.pendingWorldName = ""
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) {
|
||||||
|
state.worldName = roomTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUseRoomTitle(roomTitle, currentWorldName string) bool {
|
||||||
|
if roomTitle == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(roomTitle, "Home Location") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if currentWorldName == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if roomTitle == currentWorldName {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(roomTitle) > len(currentWorldName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
|
||||||
|
if roomTitle == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(roomTitle, "Home Location") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if currentWorldName == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if roomTitle == currentWorldName {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLineTime(line string) time.Time {
|
||||||
|
if len(line) < 19 {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
|
||||||
|
if err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package buildinfo
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
16
internal/common/project_paths.go
Normal file
16
internal/common/project_paths.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RuntimeBaseDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
|
||||||
|
func RootDir() string { return RuntimeBaseDir() }
|
||||||
@@ -3,12 +3,16 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OSC OSCConfig
|
OSC OSCConfig
|
||||||
|
VrcLog VrcLogConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type OSCConfig struct {
|
type OSCConfig struct {
|
||||||
@@ -16,8 +20,24 @@ type OSCConfig struct {
|
|||||||
Port int
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VrcLogConfig struct {
|
||||||
|
SelfName string
|
||||||
|
GuestNames []string
|
||||||
|
GuestFile string
|
||||||
|
StaffNames []string
|
||||||
|
MissingCount int
|
||||||
|
LogPatterns []string
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
cfg := &Config{OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}}
|
cfg := &Config{
|
||||||
|
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
||||||
|
} else if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(common.RootDir(), path)
|
||||||
|
}
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -28,6 +48,7 @@ func Load(path string) (*Config, error) {
|
|||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
line = strings.TrimPrefix(line, "\ufeff")
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -42,7 +63,8 @@ func Load(path string) (*Config, error) {
|
|||||||
key := strings.TrimSpace(parts[0])
|
key := strings.TrimSpace(parts[0])
|
||||||
val := strings.TrimSpace(parts[1])
|
val := strings.TrimSpace(parts[1])
|
||||||
val = strings.Trim(val, "\"'")
|
val = strings.Trim(val, "\"'")
|
||||||
if section == "osc" {
|
switch section {
|
||||||
|
case "osc":
|
||||||
switch key {
|
switch key {
|
||||||
case "host":
|
case "host":
|
||||||
cfg.OSC.Host = val
|
cfg.OSC.Host = val
|
||||||
@@ -51,8 +73,74 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.OSC.Port = n
|
cfg.OSC.Port = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "self":
|
||||||
|
if key == "name" {
|
||||||
|
cfg.VrcLog.SelfName = val
|
||||||
|
}
|
||||||
|
case "notice":
|
||||||
|
if key == "missing_count" {
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
cfg.VrcLog.MissingCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "vrc_log":
|
||||||
|
if key == "patterns" {
|
||||||
|
cfg.VrcLog.LogPatterns = parseList(val)
|
||||||
|
}
|
||||||
|
case "staff":
|
||||||
|
if key == "names" {
|
||||||
|
cfg.VrcLog.StaffNames = parseList(val)
|
||||||
|
}
|
||||||
|
case "guest":
|
||||||
|
switch key {
|
||||||
|
case "names":
|
||||||
|
cfg.VrcLog.GuestNames = parseList(val)
|
||||||
|
case "file":
|
||||||
|
cfg.VrcLog.GuestFile = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.VrcLog.GuestFile != "" {
|
||||||
|
if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
|
||||||
|
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
|
||||||
|
}
|
||||||
|
if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
|
||||||
|
cfg.VrcLog.GuestNames = names
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, scanner.Err()
|
return cfg, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseList(value string) []string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "[")
|
||||||
|
value = strings.TrimSuffix(value, "]")
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(strings.Trim(part, "\"'"))
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLines(path string) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||||
|
if line != "" && !strings.HasPrefix(line, "#") {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
144
internal/consenttool/config.go
Normal file
144
internal/consenttool/config.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package consenttool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Consent ConsentConfig
|
||||||
|
Log LogConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConsentConfig struct {
|
||||||
|
InputCSV string
|
||||||
|
OutputJSON string
|
||||||
|
DisplayFields []string
|
||||||
|
ConsentFields []string
|
||||||
|
ConsentValues []string
|
||||||
|
NormalizeCase bool
|
||||||
|
AllowDuplicates bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
InputPath string
|
||||||
|
OutputPath string
|
||||||
|
IncludeRegex string
|
||||||
|
KeepNonMatch bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
Consent: ConsentConfig{
|
||||||
|
InputCSV: "config/consent.csv",
|
||||||
|
OutputJSON: "config/consent_list.json",
|
||||||
|
DisplayFields: []string{"display_name", "vrchat_name", "name"},
|
||||||
|
ConsentFields: []string{"consent", "agree", "agreed", "checked"},
|
||||||
|
ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"},
|
||||||
|
NormalizeCase: false,
|
||||||
|
AllowDuplicates: false,
|
||||||
|
},
|
||||||
|
Log: LogConfig{
|
||||||
|
InputPath: "runtime/output_log.txt",
|
||||||
|
OutputPath: "runtime/research_log.txt",
|
||||||
|
IncludeRegex: `\[[A-Z0-9:_ -]+\]`,
|
||||||
|
KeepNonMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var section string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
line = strings.TrimPrefix(line, "\ufeff")
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||||
|
section = strings.Trim(line, "[]")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
val = strings.Trim(val, "\"'")
|
||||||
|
|
||||||
|
switch section {
|
||||||
|
case "consent":
|
||||||
|
switch key {
|
||||||
|
case "input_csv":
|
||||||
|
cfg.Consent.InputCSV = val
|
||||||
|
case "output_json":
|
||||||
|
cfg.Consent.OutputJSON = val
|
||||||
|
case "display_fields":
|
||||||
|
cfg.Consent.DisplayFields = parseList(val)
|
||||||
|
case "consent_fields":
|
||||||
|
cfg.Consent.ConsentFields = parseList(val)
|
||||||
|
case "consent_values":
|
||||||
|
cfg.Consent.ConsentValues = parseList(val)
|
||||||
|
case "normalize_case":
|
||||||
|
cfg.Consent.NormalizeCase = parseBool(val)
|
||||||
|
case "allow_duplicates":
|
||||||
|
cfg.Consent.AllowDuplicates = parseBool(val)
|
||||||
|
}
|
||||||
|
case "log":
|
||||||
|
switch key {
|
||||||
|
case "input_path":
|
||||||
|
cfg.Log.InputPath = val
|
||||||
|
case "output_path":
|
||||||
|
cfg.Log.OutputPath = val
|
||||||
|
case "include_regex":
|
||||||
|
cfg.Log.IncludeRegex = val
|
||||||
|
case "keep_non_match":
|
||||||
|
cfg.Log.KeepNonMatch = parseBool(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseList(value string) []string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "[")
|
||||||
|
value = strings.TrimSuffix(value, "]")
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
item = strings.Trim(item, "\"'")
|
||||||
|
if item != "" {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) bool {
|
||||||
|
n, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||||
|
return err == nil && n
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePath(baseDir, value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(baseDir, value))
|
||||||
|
}
|
||||||
183
internal/consenttool/tool.go
Normal file
183
internal/consenttool/tool.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package consenttool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(configPath string, mode string) error {
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseDir := filepath.Dir(configPath)
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "generate-json":
|
||||||
|
return generateJSON(cfg, baseDir)
|
||||||
|
case "extract-log":
|
||||||
|
return extractLog(cfg, baseDir)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateJSON(cfg *Config, baseDir string) error {
|
||||||
|
inputPath := resolvePath(baseDir, cfg.Consent.InputCSV)
|
||||||
|
outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON)
|
||||||
|
|
||||||
|
rows, err := readCSVRows(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := collectConsentedNames(rows, cfg.Consent)
|
||||||
|
body, err := json.MarshalIndent(names, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(outputPath, append(body, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLog(cfg *Config, baseDir string) error {
|
||||||
|
inputPath := resolvePath(baseDir, cfg.Log.InputPath)
|
||||||
|
outputPath := resolvePath(baseDir, cfg.Log.OutputPath)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
re, err := regexp.Compile(cfg.Log.IncludeRegex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept []string
|
||||||
|
for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
||||||
|
if re.MatchString(line) {
|
||||||
|
kept = append(kept, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cfg.Log.KeepNonMatch {
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.Join(kept, "\n")
|
||||||
|
if body != "" {
|
||||||
|
body += "\n"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(outputPath, []byte(body), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCSVRows(path string) ([]map[string]string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
r := csv.NewReader(file)
|
||||||
|
headers, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i, header := range headers {
|
||||||
|
headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []map[string]string
|
||||||
|
for {
|
||||||
|
record, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row := make(map[string]string, len(headers))
|
||||||
|
for i, header := range headers {
|
||||||
|
if i < len(record) {
|
||||||
|
row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string {
|
||||||
|
displayFields := cfg.DisplayFields
|
||||||
|
if len(displayFields) == 0 {
|
||||||
|
displayFields = []string{"display_name", "vrchat_name", "name"}
|
||||||
|
}
|
||||||
|
consentFields := cfg.ConsentFields
|
||||||
|
if len(consentFields) == 0 {
|
||||||
|
consentFields = []string{"consent", "agree", "agreed", "checked"}
|
||||||
|
}
|
||||||
|
accepted := make(map[string]struct{})
|
||||||
|
var names []string
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
if !rowIsConsented(row, consentFields, cfg.ConsentValues) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := firstNonEmpty(row, displayFields)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := name
|
||||||
|
if cfg.NormalizeCase {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
}
|
||||||
|
if !cfg.AllowDuplicates {
|
||||||
|
if _, ok := accepted[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accepted[key] = struct{}{}
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowIsConsented(row map[string]string, fields []string, values []string) bool {
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = []string{"1", "true", "yes", "y", "on", "checked"}
|
||||||
|
}
|
||||||
|
allowed := make(map[string]struct{}, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if v, ok := row[field]; ok {
|
||||||
|
if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(row map[string]string, fields []string) string {
|
||||||
|
for _, field := range fields {
|
||||||
|
if v := strings.TrimSpace(row[field]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
44
internal/consenttool/tool_test.go
Normal file
44
internal/consenttool/tool_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package consenttool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectConsentedNames(t *testing.T) {
|
||||||
|
rows := []map[string]string{
|
||||||
|
{"display_name": "Alice", "consent": "true"},
|
||||||
|
{"display_name": "Bob", "consent": "false"},
|
||||||
|
{"display_name": "Alice", "consent": "yes"},
|
||||||
|
}
|
||||||
|
|
||||||
|
names := collectConsentedNames(rows, ConsentConfig{})
|
||||||
|
if len(names) != 1 || names[0] != "Alice" {
|
||||||
|
t.Fatalf("unexpected names: %#v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLog(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &Config{
|
||||||
|
Log: LogConfig{
|
||||||
|
InputPath: "input.log",
|
||||||
|
OutputPath: "out.log",
|
||||||
|
IncludeRegex: `ID:[0-9]+`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if err := extractLog(cfg, dir); err != nil {
|
||||||
|
t.Fatalf("extractLog: %v", err)
|
||||||
|
}
|
||||||
|
out, err := os.ReadFile(filepath.Join(dir, "out.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(out) != "ID:1 hello\n" {
|
||||||
|
t.Fatalf("unexpected output: %q", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package osc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,7 +35,12 @@ func NewServer(host string, port int) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
|
||||||
func (s *Server) Close() error { if s.conn != nil { return s.conn.Close() }; return nil }
|
func (s *Server) Close() error {
|
||||||
|
if s.conn != nil {
|
||||||
|
return s.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Serve() error {
|
func (s *Server) Serve() error {
|
||||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||||
@@ -65,13 +72,70 @@ func (s *Server) Serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseMessage(b []byte) (string, []Value, error) {
|
func parseMessage(b []byte) (string, []Value, error) {
|
||||||
parts := bytes.SplitN(b, []byte{0}, 2)
|
address, offset, err := parsePaddedString(b, 0)
|
||||||
if len(parts) == 0 {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("invalid osc")
|
return "", nil, err
|
||||||
}
|
}
|
||||||
address := string(parts[0])
|
|
||||||
if !strings.HasPrefix(address, "/") {
|
if !strings.HasPrefix(address, "/") {
|
||||||
return "", nil, fmt.Errorf("invalid address")
|
return "", nil, fmt.Errorf("invalid address")
|
||||||
}
|
}
|
||||||
return address, nil, nil
|
if offset >= len(b) {
|
||||||
|
return address, nil, nil
|
||||||
|
}
|
||||||
|
typetags, offset, err := parsePaddedString(b, offset)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(typetags, ",") {
|
||||||
|
return address, nil, nil
|
||||||
|
}
|
||||||
|
var values []Value
|
||||||
|
for _, tag := range typetags[1:] {
|
||||||
|
switch tag {
|
||||||
|
case 'i':
|
||||||
|
if offset+4 > len(b) {
|
||||||
|
return "", nil, fmt.Errorf("invalid int")
|
||||||
|
}
|
||||||
|
values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))})
|
||||||
|
offset += 4
|
||||||
|
case 'f':
|
||||||
|
if offset+4 > len(b) {
|
||||||
|
return "", nil, fmt.Errorf("invalid float")
|
||||||
|
}
|
||||||
|
bits := binary.BigEndian.Uint32(b[offset : offset+4])
|
||||||
|
values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)})
|
||||||
|
offset += 4
|
||||||
|
case 's':
|
||||||
|
s, next, err := parsePaddedString(b, offset)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
values = append(values, Value{Type: 's', Str: s})
|
||||||
|
offset = next
|
||||||
|
case 'T':
|
||||||
|
values = append(values, Value{Type: 'T', Bool: true})
|
||||||
|
case 'F':
|
||||||
|
values = append(values, Value{Type: 'F', Bool: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return address, values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePaddedString(b []byte, offset int) (string, int, error) {
|
||||||
|
if offset >= len(b) {
|
||||||
|
return "", offset, fmt.Errorf("invalid osc string")
|
||||||
|
}
|
||||||
|
end := bytes.IndexByte(b[offset:], 0)
|
||||||
|
if end < 0 {
|
||||||
|
return "", offset, fmt.Errorf("invalid osc string")
|
||||||
|
}
|
||||||
|
s := string(b[offset : offset+end])
|
||||||
|
next := offset + end + 1
|
||||||
|
for next%4 != 0 {
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
if next > len(b) {
|
||||||
|
next = len(b)
|
||||||
|
}
|
||||||
|
return s, next, nil
|
||||||
}
|
}
|
||||||
|
|||||||
106
internal/update/manager.go
Normal file
106
internal/update/manager.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"vrc_osc_go/internal/buildinfo"
|
||||||
|
"vrc_osc_go/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
Client *Client
|
||||||
|
OwnerRepo string
|
||||||
|
AssetName string
|
||||||
|
CurrentLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) CheckAndUpdate() (bool, error) {
|
||||||
|
if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
latest, err := m.Client.LatestRelease(m.OwnerRepo)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if latest.TagName == "" || latest.TagName == m.CurrentLabel {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
assetURL := ""
|
||||||
|
for _, a := range latest.Assets {
|
||||||
|
if a.Name == m.AssetName {
|
||||||
|
assetURL = a.URL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assetURL == "" {
|
||||||
|
return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName)
|
||||||
|
}
|
||||||
|
base := common.RootDir()
|
||||||
|
tmpDir := filepath.Join(base, "update", latest.TagName)
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
zipPath := filepath.Join(tmpDir, m.AssetName)
|
||||||
|
log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName)
|
||||||
|
if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := ExtractZip(zipPath, tmpDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
exePath := filepath.Join(base, CurrentExeName())
|
||||||
|
newExe := filepath.Join(tmpDir, CurrentExeName())
|
||||||
|
if _, err := os.Stat(newExe); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
backup := exePath + ".bak"
|
||||||
|
_ = os.Remove(backup)
|
||||||
|
if err := os.Rename(exePath, backup); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := CopyFile(newExe, exePath); err != nil {
|
||||||
|
_ = os.Rename(backup, exePath)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_ = os.Remove(backup)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Launch(exe string, args []string) error {
|
||||||
|
cmd := exec.Command(exe, args...)
|
||||||
|
cmd.Dir = filepath.Dir(exe)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Version() string { return buildinfo.Version }
|
||||||
|
|
||||||
|
func CurrentExeName() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "vrc_osc.exe"
|
||||||
|
}
|
||||||
|
return "vrc_osc"
|
||||||
|
}
|
||||||
125
internal/update/release.go
Normal file
125
internal/update/release.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReleaseInfo struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info ReleaseInfo
|
||||||
|
if err := c.doJSON(req, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DownloadAsset(url, dst string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractZip(zipPath, dstDir string) error {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
root := filepath.Clean(dstDir) + string(os.PathSeparator)
|
||||||
|
for _, f := range zr.File {
|
||||||
|
target := filepath.Clean(filepath.Join(dstDir, f.Name))
|
||||||
|
if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) {
|
||||||
|
return fmt.Errorf("zip path escapes destination: %s", f.Name)
|
||||||
|
}
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(out, rc)
|
||||||
|
out.Close()
|
||||||
|
rc.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) apiURL(ownerRepo, suffix string) string {
|
||||||
|
base := strings.TrimRight(c.BaseURL, "/")
|
||||||
|
return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(req *http.Request, out any) error {
|
||||||
|
resp, err := c.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) httpClient() *http.Client {
|
||||||
|
if c.HTTP != nil {
|
||||||
|
return c.HTTP
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user