Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s

This commit is contained in:
messypy
2026-07-27 23:01:24 +09:00
parent 3ff33e40e3
commit dd165d9301
80 changed files with 2908 additions and 5661 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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':

View File

@@ -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()

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

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

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

View File

@@ -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 {

View File

@@ -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))
}
}()
}

View File

@@ -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),
})
}

View File

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

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

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