diff --git a/.gitea/workflows/build-windows-exe.yml b/.gitea/workflows/build-windows-exe.yml index f093bda..09a2afa 100644 --- a/.gitea/workflows/build-windows-exe.yml +++ b/.gitea/workflows/build-windows-exe.yml @@ -37,6 +37,7 @@ jobs: 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_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 - name: Package executables @@ -46,7 +47,7 @@ jobs: cp config/config.example.toml dist/config.example.toml cp config/secrets.example.toml dist/secrets.example.toml 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 env: diff --git a/cmd/vrc_osc/main.go b/cmd/vrc_osc/main.go index fed460f..67bca30 100644 --- a/cmd/vrc_osc/main.go +++ b/cmd/vrc_osc/main.go @@ -4,18 +4,39 @@ import ( "flag" "log" "os" + "os/exec" + "path/filepath" "vrc_osc_go/internal/app" ) func main() { var configPath string + var mode string + var noGUI bool 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() - if err := app.Run(configPath); err != nil { + if mode == "" && !noGUI { + launchGUI() + } + + if err := app.Run(configPath, mode); err != nil { log.SetOutput(os.Stderr) 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() +} diff --git a/cmd/vrc_osc_gui/main.go b/cmd/vrc_osc_gui/main.go new file mode 100644 index 0000000..fbe31c2 --- /dev/null +++ b/cmd/vrc_osc_gui/main.go @@ -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()) +} diff --git a/cmd/vrc_osc_gui/tray_windows.go b/cmd/vrc_osc_gui/tray_windows.go new file mode 100644 index 0000000..6218cbe --- /dev/null +++ b/cmd/vrc_osc_gui/tray_windows.go @@ -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 +} diff --git a/cmd/vrc_osc_gui/window_windows.go b/cmd/vrc_osc_gui/window_windows.go new file mode 100644 index 0000000..edc1a4c --- /dev/null +++ b/cmd/vrc_osc_gui/window_windows.go @@ -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)) +} diff --git a/cmd/vrc_osc_launcher/main.go b/cmd/vrc_osc_launcher/main.go index d3d6155..07f7bdb 100644 --- a/cmd/vrc_osc_launcher/main.go +++ b/cmd/vrc_osc_launcher/main.go @@ -4,8 +4,10 @@ import ( "flag" "log" "os" + "os/exec" "path/filepath" "syscall" + "unsafe" "vrc_osc_go/internal/buildinfo" "vrc_osc_go/internal/common" @@ -31,12 +33,18 @@ func main() { if _, err := mgr.CheckAndUpdate(); err != nil { 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 { showError("vrc_osc_launcher", "missing exe: "+err.Error()) 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()) log.Fatalf("launch failed: %v", err) } @@ -49,8 +57,19 @@ func showError(title, message string) { const mbIconError = 0x00000010 _, _, _ = msgBox.Call( 0, - uintptr(syscall.StringToUTF16Ptr(message)), - uintptr(syscall.StringToUTF16Ptr(title)), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), 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 +} diff --git a/cmd/vrwt_tool/main.go b/cmd/vrwt_tool/main.go new file mode 100644 index 0000000..dda9087 --- /dev/null +++ b/cmd/vrwt_tool/main.go @@ -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) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index c6a6f96..5960fb1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,37 +1,75 @@ package app import ( - "fmt" + "log" "os" "os/signal" + "sync" "syscall" + "time" "vrc_osc_go/internal/config" + "vrc_osc_go/internal/consenttool" "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) if err != nil { 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) + discordState := &discordMuteState{muted: true} + SetDiscordMuted(true) 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) - 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) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) select { case err := <-errCh: + log.Printf("osc server stopped: %v", err) return err case <-sigCh: + log.Printf("shutdown signal received") server.Close() return nil } } - diff --git a/internal/app/discord_mute.go b/internal/app/discord_mute.go new file mode 100644 index 0000000..7165d9d --- /dev/null +++ b/internal/app/discord_mute.go @@ -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) + } +} diff --git a/internal/app/guest_status.go b/internal/app/guest_status.go new file mode 100644 index 0000000..15139fe --- /dev/null +++ b/internal/app/guest_status.go @@ -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) +} diff --git a/internal/app/ocr.go b/internal/app/ocr.go new file mode 100644 index 0000000..6f6e2ca --- /dev/null +++ b/internal/app/ocr.go @@ -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 +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go new file mode 100644 index 0000000..d306a7b --- /dev/null +++ b/internal/app/runtime.go @@ -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 +} diff --git a/internal/app/self_monitor.go b/internal/app/self_monitor.go new file mode 100644 index 0000000..6ac10a6 --- /dev/null +++ b/internal/app/self_monitor.go @@ -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 +} diff --git a/internal/app/state.go b/internal/app/state.go new file mode 100644 index 0000000..4240c98 --- /dev/null +++ b/internal/app/state.go @@ -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), + }) +} diff --git a/internal/app/vrc_log.go b/internal/app/vrc_log.go new file mode 100644 index 0000000..9a1fdf8 --- /dev/null +++ b/internal/app/vrc_log.go @@ -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() +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..f63e477 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,6 @@ +package buildinfo + +var ( + Version = "dev" + BuildTime = "unknown" +) diff --git a/internal/common/project_paths.go b/internal/common/project_paths.go new file mode 100644 index 0000000..76b587c --- /dev/null +++ b/internal/common/project_paths.go @@ -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() } diff --git a/internal/config/config.go b/internal/config/config.go index ed1319b..e584686 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,12 +3,16 @@ package config import ( "bufio" "os" + "path/filepath" "strconv" "strings" + + "vrc_osc_go/internal/common" ) type Config struct { - OSC OSCConfig + OSC OSCConfig + VrcLog VrcLogConfig } type OSCConfig struct { @@ -16,8 +20,24 @@ type OSCConfig struct { Port int } +type VrcLogConfig struct { + SelfName string + GuestNames []string + GuestFile string + StaffNames []string + MissingCount int + LogPatterns []string +} + 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) if err != nil { return cfg, nil @@ -28,6 +48,7 @@ func Load(path string) (*Config, error) { scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) + line = strings.TrimPrefix(line, "\ufeff") if line == "" || strings.HasPrefix(line, "#") { continue } @@ -42,7 +63,8 @@ func Load(path string) (*Config, error) { key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) val = strings.Trim(val, "\"'") - if section == "osc" { + switch section { + case "osc": switch key { case "host": cfg.OSC.Host = val @@ -51,8 +73,74 @@ func Load(path string) (*Config, error) { 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() } +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 +} diff --git a/internal/consenttool/config.go b/internal/consenttool/config.go new file mode 100644 index 0000000..702f2ca --- /dev/null +++ b/internal/consenttool/config.go @@ -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)) +} diff --git a/internal/consenttool/tool.go b/internal/consenttool/tool.go new file mode 100644 index 0000000..1a58dd2 --- /dev/null +++ b/internal/consenttool/tool.go @@ -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 "" +} diff --git a/internal/consenttool/tool_test.go b/internal/consenttool/tool_test.go new file mode 100644 index 0000000..a7ef4b6 --- /dev/null +++ b/internal/consenttool/tool_test.go @@ -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)) + } +} diff --git a/internal/osc/server.go b/internal/osc/server.go index c56871c..2488509 100644 --- a/internal/osc/server.go +++ b/internal/osc/server.go @@ -2,7 +2,9 @@ package osc import ( "bytes" + "encoding/binary" "fmt" + "math" "net" "strings" "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) 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 { addr, err := net.ResolveUDPAddr("udp", s.addr) @@ -65,13 +72,70 @@ func (s *Server) Serve() error { } func parseMessage(b []byte) (string, []Value, error) { - parts := bytes.SplitN(b, []byte{0}, 2) - if len(parts) == 0 { - return "", nil, fmt.Errorf("invalid osc") + address, offset, err := parsePaddedString(b, 0) + if err != nil { + return "", nil, err } - address := string(parts[0]) if !strings.HasPrefix(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 } diff --git a/internal/update/manager.go b/internal/update/manager.go new file mode 100644 index 0000000..e377fab --- /dev/null +++ b/internal/update/manager.go @@ -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" +} diff --git a/internal/update/release.go b/internal/update/release.go new file mode 100644 index 0000000..8fff9f1 --- /dev/null +++ b/internal/update/release.go @@ -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} +}