532 lines
14 KiB
Go
532 lines
14 KiB
Go
//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))
|
|
}
|