Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s
Some checks failed
build-windows-exe / build (push) Failing after 1m13s
This commit is contained in:
@@ -8,9 +8,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ func Run(configPath string, mode string) error {
|
||||
}
|
||||
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
|
||||
InitJoinLeaveEventTracker(NewJoinLeaveEventTracker())
|
||||
InitWorldHistoryTracker(NewWorldHistoryTracker())
|
||||
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)
|
||||
@@ -89,6 +91,9 @@ func Run(configPath string, mode string) error {
|
||||
return err
|
||||
case <-sigCh:
|
||||
log.Printf("shutdown signal received")
|
||||
if tracker := GetWorldHistoryTracker(); tracker != nil {
|
||||
tracker.FinalizeCurrent(time.Now())
|
||||
}
|
||||
server.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,87 +3,82 @@ package app
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
vkControl = 0x11
|
||||
vkShift = 0x10
|
||||
vkM = 0x4D
|
||||
keyUp = 0x0002
|
||||
swRestore = 9
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func pressDiscordMuteHotkey() error {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
enumWindows := user32.NewProc("EnumWindows")
|
||||
getWindowTextW := user32.NewProc("GetWindowTextW")
|
||||
isWindowVisible := user32.NewProc("IsWindowVisible")
|
||||
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||
showWindow := user32.NewProc("ShowWindow")
|
||||
getWindowRect := user32.NewProc("GetWindowRect")
|
||||
keybdEvent := user32.NewProc("keybd_event")
|
||||
setCursorPos := user32.NewProc("SetCursorPos")
|
||||
sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep")
|
||||
script := `
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
var target uintptr
|
||||
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
|
||||
vis, _, _ := isWindowVisible.Call(hwnd)
|
||||
if vis == 0 {
|
||||
return 1
|
||||
}
|
||||
buf := make([]uint16, 512)
|
||||
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
|
||||
if !strings.Contains(title, "discord") {
|
||||
return 1
|
||||
}
|
||||
target = hwnd
|
||||
return 0
|
||||
})
|
||||
r1, _, err1 := enumWindows.Call(cb, 0)
|
||||
if r1 == 0 && target == 0 {
|
||||
return fmt.Errorf("discord window not found: %v", err1)
|
||||
}
|
||||
if target == 0 {
|
||||
return fmt.Errorf("discord window not found")
|
||||
}
|
||||
function Get-DiscordWindows {
|
||||
$windows = foreach ($p in Get-Process) {
|
||||
$title = ($p.MainWindowTitle | Out-String).Trim()
|
||||
if (-not $title) { continue }
|
||||
if (-not $title.ToLower().Contains('discord')) { continue }
|
||||
$p
|
||||
}
|
||||
if (-not $windows) { return @() }
|
||||
return $windows
|
||||
}
|
||||
|
||||
showWindow.Call(target, swRestore)
|
||||
setForegroundWindow.Call(target)
|
||||
sleep.Call(150)
|
||||
function Get-BestDiscordWindow {
|
||||
$windows = Get-DiscordWindows
|
||||
if (-not $windows -or $windows.Count -eq 0) { return $null }
|
||||
|
||||
type rect struct {
|
||||
Left int32
|
||||
Top int32
|
||||
Right int32
|
||||
Bottom int32
|
||||
}
|
||||
var r rect
|
||||
if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 {
|
||||
x := int((r.Left + r.Right) / 2)
|
||||
y := int((r.Top + r.Bottom) / 2)
|
||||
setCursorPos.Call(uintptr(x), uintptr(y))
|
||||
sleep.Call(50)
|
||||
}
|
||||
$browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
|
||||
$ranked = $windows | Sort-Object {
|
||||
$title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
|
||||
$score = 0
|
||||
foreach ($keyword in $browserKeywords) {
|
||||
if ($title.Contains($keyword)) { $score += 10 }
|
||||
}
|
||||
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
|
||||
if ($title.Contains('discord')) { $score += 5 }
|
||||
$score
|
||||
} -Descending
|
||||
|
||||
send := func(vk byte, flags uintptr) {
|
||||
keybdEvent.Call(uintptr(vk), 0, flags, 0)
|
||||
}
|
||||
send(vkControl, 0)
|
||||
send(vkShift, 0)
|
||||
send(vkM, 0)
|
||||
send(vkM, keyUp)
|
||||
send(vkShift, keyUp)
|
||||
send(vkControl, keyUp)
|
||||
sleep.Call(100)
|
||||
return $ranked | Select-Object -First 1
|
||||
}
|
||||
|
||||
SetDiscordWindow("Discord")
|
||||
log.Printf("discord hotkey sent via keybd_event")
|
||||
function Get-VRChatWindow {
|
||||
$windows = Get-Process | Where-Object {
|
||||
$_.MainWindowTitle -and (
|
||||
$_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ')
|
||||
)
|
||||
}
|
||||
if (-not $windows) { return $null }
|
||||
return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Activate-Window($p) {
|
||||
if ($null -eq $p) { return }
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
|
||||
$discord = Get-BestDiscordWindow
|
||||
if ($null -eq $discord) { exit 2 }
|
||||
|
||||
Activate-Window $discord
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
Start-Sleep -Milliseconds 200
|
||||
$wshell.SendKeys('^+m')
|
||||
Start-Sleep -Milliseconds 200
|
||||
|
||||
$vrchat = Get-VRChatWindow
|
||||
if ($null -ne $vrchat) {
|
||||
Activate-Window $vrchat
|
||||
}
|
||||
`
|
||||
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
log.Printf("discord mute output: %s", string(out))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord mute failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,6 +54,41 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmuteDiscordIfMuted(state *discordMuteState, source string) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("discord state is nil")
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if !state.muted {
|
||||
log.Printf("INFO %s requested Discord unmute but already unmuted", source)
|
||||
SetDiscordAction("already_unmuted")
|
||||
SetDiscordSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("ACTION %s -> Discord unmute hotkey", source)
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
log.Printf("discord unmute hotkey failed: %v", err)
|
||||
SetDiscordAction("hotkey_failed")
|
||||
SetDiscordSource(source + "_error")
|
||||
state.lastAction = now
|
||||
return err
|
||||
}
|
||||
state.muted = false
|
||||
state.lastAction = now
|
||||
SetDiscordMuted(false)
|
||||
SetDiscordAction("unmuted")
|
||||
SetDiscordSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBool(v osc.Value) (bool, error) {
|
||||
switch v.Type {
|
||||
case 'T':
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -40,6 +40,14 @@ func (t *GuestTracker) SetCurrentInstance(label string) {
|
||||
t.current = label
|
||||
}
|
||||
|
||||
func (t *GuestTracker) ResetCurrentInstance(label string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.current = label
|
||||
t.items = map[string]*GuestStatus{}
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *GuestTracker) CurrentInstance() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
214
internal/app/join_leave_events.go
Normal file
214
internal/app/join_leave_events.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
const joinLeaveEventsFileName = "join_leave_events.json"
|
||||
|
||||
var joinLeaveEventLinePattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
||||
|
||||
type JoinLeaveEvent struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type joinLeaveEventsSnapshot struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Events []JoinLeaveEvent `json:"events"`
|
||||
}
|
||||
|
||||
type JoinLeaveEventTracker struct {
|
||||
mu sync.RWMutex
|
||||
events []JoinLeaveEvent
|
||||
}
|
||||
|
||||
var joinLeaveEventTracker *JoinLeaveEventTracker
|
||||
|
||||
func InitJoinLeaveEventTracker(t *JoinLeaveEventTracker) {
|
||||
joinLeaveEventTracker = t
|
||||
}
|
||||
|
||||
func GetJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
||||
return joinLeaveEventTracker
|
||||
}
|
||||
|
||||
func NewJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
||||
t := &JoinLeaveEventTracker{}
|
||||
_ = t.load()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) load() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
path := filepath.Join(dir, joinLeaveEventsFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil && len(b) > 0 {
|
||||
if events, ok := decodeJoinLeaveEventsSnapshot(b); ok {
|
||||
t.events = dedupeJoinLeaveEvents(events)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
events, err := loadJoinLeaveEventsFromTextLog(filepath.Join(dir, "join_leave.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.events = dedupeJoinLeaveEvents(events)
|
||||
if len(t.events) > 0 {
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJoinLeaveEventsSnapshot(b []byte) ([]JoinLeaveEvent, bool) {
|
||||
var snap joinLeaveEventsSnapshot
|
||||
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
|
||||
return snap.Events, true
|
||||
}
|
||||
var events []JoinLeaveEvent
|
||||
if err := json.Unmarshal(b, &events); err == nil {
|
||||
return events, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func loadJoinLeaveEventsFromTextLog(path string) ([]JoinLeaveEvent, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]JoinLeaveEvent, 0, 256)
|
||||
var currentAt time.Time
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "VRC JOIN/LEAVE") {
|
||||
if len(line) >= 21 {
|
||||
if at, err := time.Parse("2006-01-02 15:04:05", line[1:20]); err == nil {
|
||||
currentAt = at
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := joinLeaveEventLinePattern.FindStringSubmatch(line)
|
||||
if len(m) != 4 {
|
||||
continue
|
||||
}
|
||||
count, _ := strconv.Atoi(m[3])
|
||||
events = append(events, JoinLeaveEvent{
|
||||
At: currentAt,
|
||||
Kind: m[1],
|
||||
Name: strings.TrimSpace(m[2]),
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) Record(kind, name string, count int, at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
kind = strings.TrimSpace(kind)
|
||||
name = strings.TrimSpace(name)
|
||||
if kind != "join" && kind != "leave" {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
ev := JoinLeaveEvent{At: at, Kind: kind, Name: name, Count: count}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if containsJoinLeaveEvent(t.events, ev) {
|
||||
return
|
||||
}
|
||||
t.events = append(t.events, ev)
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) Snapshot() []JoinLeaveEvent {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := append([]JoinLeaveEvent(nil), t.events...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].At.Equal(out[j].At) {
|
||||
if out[i].Kind == out[j].Kind {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
return out[i].At.Before(out[j].At)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, joinLeaveEventsFileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(joinLeaveEventsSnapshot{
|
||||
UpdatedAt: time.Now(),
|
||||
Events: append([]JoinLeaveEvent(nil), t.events...),
|
||||
})
|
||||
}
|
||||
|
||||
func dedupeJoinLeaveEvents(events []JoinLeaveEvent) []JoinLeaveEvent {
|
||||
if len(events) <= 1 {
|
||||
return events
|
||||
}
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
out := make([]JoinLeaveEvent, 0, len(events))
|
||||
for _, ev := range events {
|
||||
key := joinLeaveEventKey(ev)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsJoinLeaveEvent(events []JoinLeaveEvent, candidate JoinLeaveEvent) bool {
|
||||
key := joinLeaveEventKey(candidate)
|
||||
for _, ev := range events {
|
||||
if joinLeaveEventKey(ev) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func joinLeaveEventKey(ev JoinLeaveEvent) string {
|
||||
return ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + ev.Name
|
||||
}
|
||||
26
internal/app/join_leave_events_test.go
Normal file
26
internal/app/join_leave_events_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeduplicateJoinLeaveEventsIgnoresRepeatedReplays(t *testing.T) {
|
||||
at := time.Date(2026, 7, 1, 1, 23, 45, 0, time.FixedZone("JST", 9*60*60))
|
||||
events := []JoinLeaveEvent{
|
||||
{At: at, Kind: "join", Name: "Alice", Count: 10},
|
||||
{At: at, Kind: "join", Name: "Alice", Count: 11},
|
||||
{At: at.Add(5 * time.Second), Kind: "leave", Name: "Alice", Count: 9},
|
||||
}
|
||||
|
||||
got := dedupeJoinLeaveEvents(events)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 unique events, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Count != 10 {
|
||||
t.Fatalf("expected first event to remain stable, got %#v", got[0])
|
||||
}
|
||||
if got[1].Kind != "leave" {
|
||||
t.Fatalf("expected leave event to remain, got %#v", got[1])
|
||||
}
|
||||
}
|
||||
28
internal/app/refresh_interval.go
Normal file
28
internal/app/refresh_interval.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
)
|
||||
|
||||
const defaultGUIRefreshInterval = 2 * time.Second
|
||||
|
||||
func guiRefreshIntervalFromConfig(cfg *config.Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return defaultGUIRefreshInterval
|
||||
}
|
||||
interval := cfg.GUI.RefreshInterval()
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func currentGUIRefreshInterval() time.Duration {
|
||||
cfg, err := config.Load("")
|
||||
if err != nil || cfg == nil {
|
||||
return defaultGUIRefreshInterval
|
||||
}
|
||||
return guiRefreshIntervalFromConfig(cfg)
|
||||
}
|
||||
@@ -26,18 +26,50 @@ func (w *runtimeLogWriter) Write(p []byte) (int, error) {
|
||||
return w.file.Write(p)
|
||||
}
|
||||
|
||||
func (w *runtimeLogWriter) ensureFile(path string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file != nil {
|
||||
return nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *runtimeLogWriter) appendf(path, format string, args ...any) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = f
|
||||
}
|
||||
_, err := fmt.Fprintf(w.file, format, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
var runtimeLogFileWriter runtimeLogWriter
|
||||
var joinLeaveLogFileWriter runtimeLogWriter
|
||||
var desktopJoinLogMu sync.Mutex
|
||||
var desktopJoinLogLastPath string
|
||||
var desktopJoinLogLastBody string
|
||||
|
||||
func setupRuntimeLogger() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runtime.log")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if err := runtimeLogFileWriter.ensureFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
logWriter := &runtimeLogWriter{file: f}
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, &runtimeLogFileWriter))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,13 +83,7 @@ func appendRuntimeLog(title, text string) error {
|
||||
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
|
||||
return runtimeLogFileWriter.appendf(path, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
|
||||
}
|
||||
|
||||
func AppendRuntimeLog(title, text string) error {
|
||||
@@ -74,13 +100,7 @@ func appendJoinLeaveLog(text string) error {
|
||||
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
|
||||
return joinLeaveLogFileWriter.appendf(path, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||
}
|
||||
|
||||
func appendDesktopJoinLog(title, worldLabel, text string) error {
|
||||
@@ -101,6 +121,11 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
|
||||
if body == "" {
|
||||
body = "(empty)"
|
||||
}
|
||||
desktopJoinLogMu.Lock()
|
||||
defer desktopJoinLogMu.Unlock()
|
||||
if desktopJoinLogLastPath == path && desktopJoinLogLastBody == body {
|
||||
return nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
fallbackDir := filepath.Join(common.RootDir(), "runtime")
|
||||
@@ -115,8 +140,12 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
|
||||
path = fallbackPath
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body)
|
||||
return err
|
||||
if _, err = fmt.Fprintf(f, "%s\n", body); err != nil {
|
||||
return err
|
||||
}
|
||||
desktopJoinLogLastPath = path
|
||||
desktopJoinLogLastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
func AppendDesktopJoinLog(title, worldLabel, text string) error {
|
||||
|
||||
@@ -2,13 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -25,31 +23,32 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
text, err := readFileTail(path, vrchatLogTailBytes)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName)
|
||||
liveCfg := cfg
|
||||
if loaded, err := config.Load(""); err == nil && loaded != nil {
|
||||
liveCfg = loaded
|
||||
}
|
||||
nextState := extractLatestSelfLogState(DecodeVRChatLog(text), liveCfg.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)
|
||||
if nextState == "left" && liveCfg.GUI.AutoUnmuteOnSelfLeave {
|
||||
if err := unmuteDiscordIfMuted(state, "self_leave"); err != nil {
|
||||
log.Printf("self monitor discord unmute failed: %v", err)
|
||||
SetDiscordSource("self_monitor_error")
|
||||
}
|
||||
} 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)
|
||||
SetDiscordSource("self_monitor_error")
|
||||
}
|
||||
log.Printf("self monitor detected leave; auto unmute disabled")
|
||||
}
|
||||
SetDiscordSource("self_monitor")
|
||||
lastState = nextState
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(guiRefreshIntervalFromConfig(liveCfg))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type RuntimeState struct {
|
||||
LastOCRText string
|
||||
LastTranslate string
|
||||
CurrentWorld string
|
||||
CurrentWorldSince string
|
||||
}
|
||||
|
||||
var runtimeState = &RuntimeState{}
|
||||
@@ -84,6 +85,29 @@ func SetCurrentWorld(v string) {
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetCurrentWorldVisit(world string, since time.Time) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.CurrentWorld = world
|
||||
if since.IsZero() {
|
||||
runtimeState.CurrentWorldSince = ""
|
||||
} else {
|
||||
runtimeState.CurrentWorldSince = since.Format(time.RFC3339)
|
||||
}
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetCurrentWorldSince(v time.Time) {
|
||||
runtimeState.mu.Lock()
|
||||
if v.IsZero() {
|
||||
runtimeState.CurrentWorldSince = ""
|
||||
} else {
|
||||
runtimeState.CurrentWorldSince = v.Format(time.RFC3339)
|
||||
}
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SnapshotRuntime() RuntimeState {
|
||||
runtimeState.mu.RLock()
|
||||
defer runtimeState.mu.RUnlock()
|
||||
@@ -111,6 +135,7 @@ func persistRuntimeState() error {
|
||||
"ocr": s.LastOCRText,
|
||||
"translate": s.LastTranslate,
|
||||
"world": s.CurrentWorld,
|
||||
"world_since": s.CurrentWorldSince,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,8 @@ var (
|
||||
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
|
||||
)
|
||||
|
||||
const vrchatLogTailBytes = 2 * 1024 * 1024
|
||||
|
||||
type vrcLogState struct {
|
||||
location string
|
||||
worldID string
|
||||
@@ -94,13 +97,13 @@ func watchVrchatLog() {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
log.Printf("vrchat log not found: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log stat failed: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
|
||||
@@ -119,7 +122,7 @@ func watchVrchatLog() {
|
||||
} else {
|
||||
lastSize = info.Size()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -128,10 +131,10 @@ func watchVrchatLog() {
|
||||
}
|
||||
if info.Size() > lastSize {
|
||||
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||||
b, err := os.ReadFile(path)
|
||||
b, err := readFileFrom(path, lastSize)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log read failed: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
text := decodeVRChatLog(b)
|
||||
@@ -146,16 +149,13 @@ func watchVrchatLog() {
|
||||
if err := appendRuntimeLog("VRC LOG", line); err != nil {
|
||||
log.Printf("append runtime log failed: %v", err)
|
||||
}
|
||||
at := extractLineTime(line)
|
||||
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)
|
||||
}
|
||||
handleWorldChange(state, worldLabel, at)
|
||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||
}
|
||||
at := extractLineTime(line)
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
log.Printf("join detected: %s", name)
|
||||
@@ -164,6 +164,9 @@ func watchVrchatLog() {
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkJoin(name, at)
|
||||
}
|
||||
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
|
||||
tracker.Record("join", name, len(state.presentSet), at)
|
||||
}
|
||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
if err := appendJoinLeaveLog(out); err != nil {
|
||||
@@ -179,6 +182,9 @@ func watchVrchatLog() {
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.MarkLeave(name, at)
|
||||
}
|
||||
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
|
||||
tracker.Record("leave", name, len(state.presentSet), at)
|
||||
}
|
||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
if err := appendJoinLeaveLog(out); err != nil {
|
||||
@@ -190,10 +196,55 @@ func watchVrchatLog() {
|
||||
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
|
||||
lastSize = info.Size()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
}
|
||||
}
|
||||
|
||||
func readFileFrom(path string, offset int64) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
if offset > 0 {
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
func readFileTail(path string, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes <= 0 {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() <= maxBytes {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(info.Size()-maxBytes, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, c := range b {
|
||||
if c == '\n' && i+1 < len(b) {
|
||||
return b[i+1:], nil
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -208,13 +259,18 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
state.initialized = false
|
||||
|
||||
text := decodeVRChatLog(b)
|
||||
var lastWorldAt time.Time
|
||||
sawWorldChange := false
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if changed, _ := updateWorldState(state, line); changed {
|
||||
sawWorldChange = true
|
||||
lastWorldAt = extractLineTime(line)
|
||||
state.presentSet = map[string]struct{}{}
|
||||
handleWorldChange(state, state.worldName, lastWorldAt)
|
||||
}
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
@@ -235,10 +291,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
}
|
||||
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)
|
||||
if !sawWorldChange {
|
||||
log.Printf("world changed: %s", state.worldName)
|
||||
if lastWorldAt.IsZero() {
|
||||
lastWorldAt = time.Now()
|
||||
}
|
||||
handleWorldChange(state, state.worldName, lastWorldAt)
|
||||
}
|
||||
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
|
||||
log.Printf("append world log failed: %v", err)
|
||||
@@ -251,9 +309,6 @@ func decodeVRChatLog(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
if utf8.Valid(b) {
|
||||
return strings.TrimPrefix(string(b), "\ufeff")
|
||||
}
|
||||
if len(b) >= 2 {
|
||||
if b[0] == 0xff && b[1] == 0xfe {
|
||||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||
@@ -270,12 +325,23 @@ func decodeVRChatLog(b []byte) string {
|
||||
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||||
}
|
||||
}
|
||||
for _, cp := range []uint32{932, 1252, 65001} {
|
||||
if decoded := decodeWithCodePage(b, cp); decoded != "" {
|
||||
return strings.TrimPrefix(fixMojibake(decoded), "\ufeff")
|
||||
lines := bytes.Split(b, []byte{'\n'})
|
||||
var out strings.Builder
|
||||
for i, raw := range lines {
|
||||
if i > 0 {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
out.WriteString(decodeVRChatLogLine(raw))
|
||||
}
|
||||
return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff")
|
||||
return strings.TrimPrefix(out.String(), "\ufeff")
|
||||
}
|
||||
|
||||
func decodeVRChatLogLine(b []byte) string {
|
||||
line := bytes.TrimRight(b, "\r")
|
||||
if len(line) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(string(line), "\ufeff")
|
||||
}
|
||||
|
||||
func DecodeVRChatLog(b []byte) string {
|
||||
@@ -402,6 +468,9 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||
nextLocation = worldID + ":" + instanceID
|
||||
}
|
||||
}
|
||||
if nextLocation == "" && roomTitle != "" {
|
||||
nextLocation = roomTitle
|
||||
}
|
||||
|
||||
if nextLocation != "" && nextLocation != state.location {
|
||||
state.location = nextLocation
|
||||
@@ -421,8 +490,8 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
|
||||
}
|
||||
if state.initialized {
|
||||
log.Printf("world changed: %s", label)
|
||||
_ = appendRuntimeLog("VRC WORLD", label)
|
||||
}
|
||||
state.initialized = true
|
||||
return true, label
|
||||
}
|
||||
|
||||
@@ -489,3 +558,16 @@ func extractLineTime(line string) time.Time {
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func handleWorldChange(state *vrcLogState, worldLabel string, at time.Time) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
SetCurrentWorldVisit(worldLabel, at)
|
||||
if tracker := GetWorldHistoryTracker(); tracker != nil {
|
||||
tracker.ObserveWorld(worldLabel, state.worldID, state.instanceID, at)
|
||||
}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.ResetCurrentInstance(worldLabel)
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/app/vrc_log_test.go
Normal file
43
internal/app/vrc_log_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindLatestVrchatLogKeepsEmptyNewestFile(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
userProfile := filepath.Join(base, "profile")
|
||||
logDir := filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir log dir: %v", err)
|
||||
}
|
||||
t.Setenv("USERPROFILE", userProfile)
|
||||
|
||||
oldPath := filepath.Join(logDir, "output_log_2026-06-29_20-46-23.txt")
|
||||
newPath := filepath.Join(logDir, "output_log_2026-06-30_22-25-51.txt")
|
||||
if err := os.WriteFile(oldPath, []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("write old log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write new log: %v", err)
|
||||
}
|
||||
oldTime := time.Date(2026, 6, 30, 22, 25, 52, 0, time.Local)
|
||||
newTime := time.Date(2026, 6, 30, 22, 26, 0, 0, time.Local)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("chtimes old log: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(newPath, newTime, newTime); err != nil {
|
||||
t.Fatalf("chtimes new log: %v", err)
|
||||
}
|
||||
|
||||
got, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
t.Fatalf("find latest log: %v", err)
|
||||
}
|
||||
if got != newPath {
|
||||
t.Fatalf("expected newest empty log, got %q want %q", got, newPath)
|
||||
}
|
||||
}
|
||||
260
internal/app/world_history.go
Normal file
260
internal/app/world_history.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
const worldHistoryFileName = "world_history.json"
|
||||
|
||||
type WorldVisit struct {
|
||||
WorldLabel string `json:"world_label"`
|
||||
WorldID string `json:"world_id,omitempty"`
|
||||
InstanceID string `json:"instance_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
type worldHistorySnapshot struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Visits []WorldVisit `json:"visits"`
|
||||
}
|
||||
|
||||
type WorldHistoryTracker struct {
|
||||
mu sync.RWMutex
|
||||
current *WorldVisit
|
||||
visits []WorldVisit
|
||||
}
|
||||
|
||||
var worldHistoryTracker *WorldHistoryTracker
|
||||
|
||||
func InitWorldHistoryTracker(t *WorldHistoryTracker) {
|
||||
worldHistoryTracker = t
|
||||
}
|
||||
|
||||
func GetWorldHistoryTracker() *WorldHistoryTracker {
|
||||
return worldHistoryTracker
|
||||
}
|
||||
|
||||
func NewWorldHistoryTracker() *WorldHistoryTracker {
|
||||
t := &WorldHistoryTracker{}
|
||||
_ = t.load()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) load() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
path := filepath.Join(dir, worldHistoryFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var snap worldHistorySnapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return err
|
||||
}
|
||||
t.visits = mergeWorldVisits(snap.Visits)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) ObserveWorld(worldLabel, worldID, instanceID string, at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
label := normalizeWorldLabel(worldLabel, worldID, instanceID)
|
||||
key := worldVisitKey(worldID, instanceID, label)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.current != nil && t.currentKey() == key {
|
||||
if t.current.WorldLabel == "" {
|
||||
t.current.WorldLabel = label
|
||||
}
|
||||
if t.current.WorldID == "" {
|
||||
t.current.WorldID = worldID
|
||||
}
|
||||
if t.current.InstanceID == "" {
|
||||
t.current.InstanceID = instanceID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if t.current != nil {
|
||||
if t.current.EndedAt.IsZero() {
|
||||
t.current.EndedAt = at
|
||||
}
|
||||
t.visits = append(t.visits, *t.current)
|
||||
}
|
||||
|
||||
t.current = &WorldVisit{
|
||||
WorldLabel: label,
|
||||
WorldID: worldID,
|
||||
InstanceID: instanceID,
|
||||
StartedAt: at,
|
||||
}
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) FinalizeCurrent(at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.current == nil {
|
||||
return
|
||||
}
|
||||
if t.current.EndedAt.IsZero() {
|
||||
t.current.EndedAt = at
|
||||
}
|
||||
t.visits = append(t.visits, *t.current)
|
||||
t.current = nil
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) Current() (WorldVisit, bool) {
|
||||
if t == nil {
|
||||
return WorldVisit{}, false
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
if t.current == nil {
|
||||
return WorldVisit{}, false
|
||||
}
|
||||
return *t.current, true
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) Snapshot() []WorldVisit {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]WorldVisit, 0, len(t.visits)+1)
|
||||
out = append(out, mergeWorldVisits(t.visits)...)
|
||||
if t.current != nil {
|
||||
out = append(out, *t.current)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ti := out[i].EndedAt
|
||||
if ti.IsZero() {
|
||||
ti = out[i].StartedAt
|
||||
}
|
||||
tj := out[j].EndedAt
|
||||
if tj.IsZero() {
|
||||
tj = out[j].StartedAt
|
||||
}
|
||||
if ti.Equal(tj) {
|
||||
return strings.ToLower(out[i].WorldLabel) < strings.ToLower(out[j].WorldLabel)
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) currentKey() string {
|
||||
if t.current == nil {
|
||||
return ""
|
||||
}
|
||||
return worldVisitKey(t.current.WorldID, t.current.InstanceID, t.current.WorldLabel)
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, worldHistoryFileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(worldHistorySnapshot{
|
||||
UpdatedAt: time.Now(),
|
||||
Visits: mergeWorldVisits(t.visits),
|
||||
})
|
||||
}
|
||||
|
||||
func mergeWorldVisits(visits []WorldVisit) []WorldVisit {
|
||||
if len(visits) <= 1 {
|
||||
return append([]WorldVisit(nil), visits...)
|
||||
}
|
||||
type bucket struct {
|
||||
visit WorldVisit
|
||||
}
|
||||
seen := make(map[string]int, len(visits))
|
||||
out := make([]bucket, 0, len(visits))
|
||||
for _, visit := range visits {
|
||||
key := worldVisitKey(visit.WorldID, visit.InstanceID, visit.WorldLabel) + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
if idx, ok := seen[key]; ok {
|
||||
if visit.EndedAt.After(out[idx].visit.EndedAt) {
|
||||
out[idx].visit.EndedAt = visit.EndedAt
|
||||
}
|
||||
if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" {
|
||||
out[idx].visit.WorldLabel = visit.WorldLabel
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[key] = len(out)
|
||||
out = append(out, bucket{visit: visit})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ti := out[i].visit.EndedAt
|
||||
if ti.IsZero() {
|
||||
ti = out[i].visit.StartedAt
|
||||
}
|
||||
tj := out[j].visit.EndedAt
|
||||
if tj.IsZero() {
|
||||
tj = out[j].visit.StartedAt
|
||||
}
|
||||
if ti.Equal(tj) {
|
||||
return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel)
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
merged := make([]WorldVisit, 0, len(out))
|
||||
for _, item := range out {
|
||||
merged = append(merged, item.visit)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func worldVisitKey(worldID, instanceID, worldLabel string) string {
|
||||
if worldID != "" {
|
||||
if instanceID != "" {
|
||||
return worldID + ":" + instanceID
|
||||
}
|
||||
return worldID
|
||||
}
|
||||
return strings.TrimSpace(worldLabel)
|
||||
}
|
||||
|
||||
func normalizeWorldLabel(worldLabel, worldID, instanceID string) string {
|
||||
label := strings.TrimSpace(worldLabel)
|
||||
if label != "" {
|
||||
return label
|
||||
}
|
||||
if worldID != "" {
|
||||
if instanceID != "" {
|
||||
return worldID + ":" + instanceID
|
||||
}
|
||||
return worldID
|
||||
}
|
||||
return "(unknown)"
|
||||
}
|
||||
@@ -33,20 +33,33 @@ type VrcLogConfig struct {
|
||||
}
|
||||
|
||||
type GUIConfig struct {
|
||||
TopMost bool
|
||||
FontSize int
|
||||
AutoUnmuteOnSelfLeave bool
|
||||
RefreshIntervalValue int
|
||||
RefreshIntervalUnit string
|
||||
HistoryRegex string
|
||||
HistoryFromDate string
|
||||
HistoryToDate string
|
||||
TopMost bool
|
||||
FontSize int
|
||||
AutoUnmuteOnSelfLeave bool
|
||||
RefreshIntervalValue int
|
||||
RefreshIntervalUnit string
|
||||
HistoryRegex string
|
||||
HistoryFromDate string
|
||||
HistoryToDate string
|
||||
HistoryExportDir string
|
||||
HistoryExportIncludeTime bool
|
||||
HistoryExportIncludeWorld bool
|
||||
HistoryExportIncludeJoinLeave bool
|
||||
HistoryExportCustomEnabled bool
|
||||
HistoryExportCustom string
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||
GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"},
|
||||
GUI: GUIConfig{
|
||||
FontSize: 18,
|
||||
RefreshIntervalValue: 2,
|
||||
RefreshIntervalUnit: "sec",
|
||||
HistoryExportIncludeTime: true,
|
||||
HistoryExportIncludeWorld: true,
|
||||
HistoryExportIncludeJoinLeave: true,
|
||||
},
|
||||
}
|
||||
if path == "" {
|
||||
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
||||
@@ -76,8 +89,7 @@ func Load(path string) (*Config, error) {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
val := parseConfigValue(parts[1])
|
||||
switch section {
|
||||
case "osc":
|
||||
switch key {
|
||||
@@ -135,6 +147,18 @@ func Load(path string) (*Config, error) {
|
||||
cfg.GUI.HistoryFromDate = normalizeHistoryDate(val)
|
||||
case "history_to":
|
||||
cfg.GUI.HistoryToDate = normalizeHistoryDate(val)
|
||||
case "history_export_dir":
|
||||
cfg.GUI.HistoryExportDir = strings.TrimSpace(val)
|
||||
case "history_export_include_time":
|
||||
cfg.GUI.HistoryExportIncludeTime = parseBool(val, true)
|
||||
case "history_export_include_world":
|
||||
cfg.GUI.HistoryExportIncludeWorld = parseBool(val, true)
|
||||
case "history_export_include_join_leave":
|
||||
cfg.GUI.HistoryExportIncludeJoinLeave = parseBool(val, true)
|
||||
case "history_export_custom_enabled":
|
||||
cfg.GUI.HistoryExportCustomEnabled = parseBool(val, false)
|
||||
case "history_export_custom":
|
||||
cfg.GUI.HistoryExportCustom = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +168,8 @@ func Load(path string) (*Config, error) {
|
||||
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit)
|
||||
cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate)
|
||||
cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate)
|
||||
cfg.GUI.HistoryExportDir = strings.TrimSpace(cfg.GUI.HistoryExportDir)
|
||||
cfg.GUI.HistoryExportCustom = strings.TrimSpace(cfg.GUI.HistoryExportCustom)
|
||||
if cfg.VrcLog.GuestFile != "" {
|
||||
if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
|
||||
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
|
||||
@@ -155,6 +181,17 @@ func Load(path string) (*Config, error) {
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
func parseConfigValue(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(raw); err == nil {
|
||||
return strings.TrimSpace(unquoted)
|
||||
}
|
||||
return strings.TrimSpace(strings.Trim(raw, "\"'"))
|
||||
}
|
||||
|
||||
func SaveGUI(path string, gui GUIConfig) error {
|
||||
path = resolvePath(path)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
@@ -263,6 +300,12 @@ func appendGUISection(out *[]string, gui GUIConfig) {
|
||||
fmt.Sprintf("history_regex = %q", gui.HistoryRegex),
|
||||
fmt.Sprintf("history_from = %q", gui.HistoryFromDate),
|
||||
fmt.Sprintf("history_to = %q", gui.HistoryToDate),
|
||||
fmt.Sprintf("history_export_dir = %q", strings.TrimSpace(gui.HistoryExportDir)),
|
||||
fmt.Sprintf("history_export_include_time = %t", gui.HistoryExportIncludeTime),
|
||||
fmt.Sprintf("history_export_include_world = %t", gui.HistoryExportIncludeWorld),
|
||||
fmt.Sprintf("history_export_include_join_leave = %t", gui.HistoryExportIncludeJoinLeave),
|
||||
fmt.Sprintf("history_export_custom_enabled = %t", gui.HistoryExportCustomEnabled),
|
||||
fmt.Sprintf("history_export_custom = %q", strings.TrimSpace(gui.HistoryExportCustom)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ func TestLoadOSCValues(t *testing.T) {
|
||||
func TestLoadGUIValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\nhistory_export_dir = \"C:/tmp/visit\"\nhistory_export_include_time = false\nhistory_export_include_world = true\nhistory_export_include_join_leave = true\nhistory_export_custom_enabled = true\nhistory_export_custom = \"[wip]\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
|
||||
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
|
||||
t.Fatalf("unexpected gui values: %+v", cfg.GUI)
|
||||
}
|
||||
}
|
||||
@@ -54,14 +54,14 @@ func TestSaveGUIUpdatesSection(t *testing.T) {
|
||||
if err := os.WriteFile(path, initial, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31"}); err != nil {
|
||||
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31", HistoryExportDir: "C:/tmp/visit", HistoryExportIncludeTime: false, HistoryExportIncludeWorld: true, HistoryExportIncludeJoinLeave: true, HistoryExportCustomEnabled: true, HistoryExportCustom: "[wip]"}); err != nil {
|
||||
t.Fatalf("SaveGUI returned error: %v", err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
|
||||
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
|
||||
t.Fatalf("unexpected saved gui values: %+v", cfg.GUI)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
Reference in New Issue
Block a user