Fix GUI instance counts and launcher tmp paths
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user