Fix GUI instance counts and launcher tmp paths

This commit is contained in:
every_holiday
2026-06-25 02:36:55 +09:00
parent 23d5fcfcda
commit 75f2d8b9c2
24 changed files with 2479 additions and 20 deletions

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