diff --git a/.gitea/workflows/build-windows-exe.yml b/.gitea/workflows/build-windows-exe.yml index 09a2afa..6e5d024 100644 --- a/.gitea/workflows/build-windows-exe.yml +++ b/.gitea/workflows/build-windows-exe.yml @@ -25,13 +25,11 @@ jobs: - name: Test shell: bash run: | - cd VRWT_Tool/VRC_OSC GO111MODULE=on go test ./... - name: Build executables shell: bash run: | - cd VRWT_Tool/VRC_OSC mkdir -p dist VERSION="${GITHUB_REF_NAME}" BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" @@ -43,7 +41,6 @@ jobs: - name: Package executables shell: bash run: | - cd VRWT_Tool/VRC_OSC cp config/config.example.toml dist/config.example.toml cp config/secrets.example.toml dist/secrets.example.toml cp config/guests.example.txt dist/guests.example.txt @@ -73,7 +70,7 @@ jobs: "${api}/repos/${owner_repo}/releases") release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') fi - asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip" + asset_path="dist/vrc_osc-windows.zip" curl -fsSL -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ -F "attachment=@${asset_path}" \ diff --git a/.gitignore b/.gitignore index 20a9c46..2bf0cd6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,28 @@ config/config.toml config/secrets.toml config/guests.txt runtime/ +dist/ +.gocache/ +.gomodcache/ +.gotelemetry/ +.gotmp/ +.telemetry/ +.appdata/ +.tmp/ .vrchat_cookie.txt src/.vrchat_cookie.txt +*.exe +*.exe~ +current-*.png +desktop-shot.png +gui-screenshot*.png +gui-settings-shot.png +gui-window-*.png +pw_*.png +screen-check.png +window-shot.png + *.bak *.bak_* backup_*/ diff --git a/.goenv b/.goenv new file mode 100644 index 0000000..d0bd437 --- /dev/null +++ b/.goenv @@ -0,0 +1 @@ +GOTELEMETRY=off diff --git a/EXE.md b/EXE.md index 26ff80c..3ac0ca6 100644 --- a/EXE.md +++ b/EXE.md @@ -27,6 +27,7 @@ Release zip を展開したフォルダに以下を置きます。 - `vrc_osc.exe` - `vrc_osc_launcher.exe` +- `vrc_osc_gui.exe` - `vrwt_tool.exe` - `config\` - `runtime\` diff --git a/VRWT_Tool/.gitea/workflows/build-windows-exe.yml b/VRWT_Tool/.gitea/workflows/build-windows-exe.yml deleted file mode 100644 index 91d0fcb..0000000 --- a/VRWT_Tool/.gitea/workflows/build-windows-exe.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: build-windows-exe - -on: - push: - -jobs: - build: - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Go - shell: pwsh - run: | - choco install golang --version=1.22.0 -y --no-progress - $env:PATH = "C:\Program Files\Go\bin;$env:PATH" - go version - - - name: Test - working-directory: VRC_OSC - shell: pwsh - run: | - $env:PATH = "C:\Program Files\Go\bin;$env:PATH" - go test ./... - - - name: Build executables - working-directory: VRC_OSC - shell: pwsh - run: | - $env:PATH = "C:\Program Files\Go\bin;$env:PATH" - go build -o dist\vrc_osc.exe ./cmd/vrc_osc - go build -o dist\vrwt_tool.exe ./cmd/vrwt_tool - - - name: Package executables - working-directory: VRC_OSC - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path dist | Out-Null - Copy-Item config\config.example.toml dist\config.example.toml - Copy-Item config\secrets.example.toml dist\secrets.example.toml - Copy-Item config\guests.example.txt dist\guests.example.txt - Compress-Archive -Path dist\vrc_osc.exe,dist\vrwt_tool.exe,dist\config.example.toml,dist\secrets.example.toml,dist\guests.example.txt -DestinationPath dist\vrc_osc-windows.zip -Force - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: vrc_osc-windows - path: VRC_OSC/dist/vrc_osc-windows.zip diff --git a/VRWT_Tool/VRC_OSC/.gitignore b/VRWT_Tool/VRC_OSC/.gitignore deleted file mode 100644 index 20a9c46..0000000 --- a/VRWT_Tool/VRC_OSC/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -__pycache__/ -*.py[cod] - -config/config.toml -config/secrets.toml -config/guests.txt -runtime/ -.vrchat_cookie.txt -src/.vrchat_cookie.txt - -*.bak -*.bak_* -backup_*/ diff --git a/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 deleted file mode 100644 index 647fa3e..0000000 --- a/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -$ErrorActionPreference = "Stop" - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RootDir = Resolve-Path (Join-Path $ScriptDir "..") -$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py" - -python $EntryPoint diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go deleted file mode 100644 index 67bca30..0000000 --- a/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "flag" - "log" - "os" - "os/exec" - "path/filepath" - - "vrc_osc_go/internal/app" -) - -func main() { - var configPath string - var mode string - var noGUI bool - flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml") - flag.StringVar(&mode, "mode", "", "generate-json or extract-log") - flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion") - flag.Parse() - - if mode == "" && !noGUI { - launchGUI() - } - - if err := app.Run(configPath, mode); err != nil { - log.SetOutput(os.Stderr) - log.Fatalf("vrc_osc_go: %v", err) - } -} - -func launchGUI() { - exe, err := os.Executable() - if err != nil { - return - } - gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe") - if _, err := os.Stat(gui); err != nil { - return - } - _ = exec.Command(gui).Start() -} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go deleted file mode 100644 index 6f8b833..0000000 --- a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" -) - -func runtimeDir() string { - exe, err := os.Executable() - if err != nil { - return "runtime" - } - return filepath.Join(filepath.Dir(exe), "runtime") -} - -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/", handleIndex) - mux.HandleFunc("/api/state", handleState) - - addr := "127.0.0.1:18181" - go openBrowser("http://" + addr) - log.Printf("gui listening on http://%s", addr) - log.Fatal(http.ListenAndServe(addr, mux)) -} - -func handleIndex(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - html := ` - - - - - VRTW_Tool - - - -

VRTW_Tool

-
loading...
- - -` - _, _ = fmt.Fprint(w, html) -} - -func handleState(w http.ResponseWriter, r *http.Request) { - statePath := filepath.Join(runtimeDir(), "state.json") - body, err := os.ReadFile(statePath) - if err != nil { - _ = json.NewEncoder(w).Encode(map[string]any{ - "world": "", - "discord_muted": false, - "ocr": "", - "translate": "", - "guests": []any{}, - "guest_count": 0, - "error": err.Error(), - }) - return - } - var state map[string]any - if err := json.Unmarshal(body, &state); err != nil { - _ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()}) - return - } - guests := loadGuestSnapshot() - state["guests"] = guests - state["guest_count"] = len(guests) - _ = json.NewEncoder(w).Encode(state) -} - -func loadGuestSnapshot() []map[string]any { - statePath := filepath.Join(runtimeDir(), "guest_snapshot.json") - body, err := os.ReadFile(statePath) - if err != nil { - return []map[string]any{} - } - var guests []map[string]any - if err := json.Unmarshal(body, &guests); err != nil { - return []map[string]any{} - } - return guests -} - -func openBrowser(url string) { - switch runtime.GOOS { - case "windows": - _ = exec.Command("cmd", "/c", "start", "", url).Start() - default: - _ = exec.Command("xdg-open", url).Start() - } -} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go deleted file mode 100644 index 87a681f..0000000 --- a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "flag" - "log" - "os" - "path/filepath" - - "vrc_osc_go/internal/buildinfo" - "vrc_osc_go/internal/common" - "vrc_osc_go/internal/update" -) - -func main() { - var baseURL string - var ownerRepo string - var assetName string - flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL") - flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo") - flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name") - flag.Parse() - - client := &update.Client{BaseURL: baseURL} - mgr := &update.Manager{ - Client: client, - OwnerRepo: ownerRepo, - AssetName: assetName, - CurrentLabel: buildinfo.Version, - } - if _, err := mgr.CheckAndUpdate(); err != nil { - log.Printf("auto update skipped: %v", err) - } - exe := filepath.Join(common.RootDir(), "vrc_osc.exe") - if _, err := os.Stat(exe); err != nil { - log.Fatalf("missing exe: %v", err) - } - if err := update.Launch(exe, os.Args[1:]); err != nil { - log.Fatalf("launch failed: %v", err) - } -} diff --git a/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go b/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go deleted file mode 100644 index dda9087..0000000 --- a/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - - "vrc_osc_go/internal/consenttool" -) - -func main() { - var cfgPath string - var mode string - - flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config") - flag.StringVar(&mode, "mode", "", "generate-json or extract-log") - flag.Parse() - - if mode == "" && flag.NArg() > 0 { - mode = flag.Arg(0) - } - - if mode == "" { - fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log") - os.Exit(2) - } - - if err := consenttool.Run(cfgPath, mode); err != nil { - log.SetOutput(os.Stderr) - log.Fatalf("vrwt_tool: %v", err) - } -} diff --git a/VRWT_Tool/VRC_OSC/config/config.example.toml b/VRWT_Tool/VRC_OSC/config/config.example.toml deleted file mode 100644 index e154d9e..0000000 --- a/VRWT_Tool/VRC_OSC/config/config.example.toml +++ /dev/null @@ -1,22 +0,0 @@ -[self] -name = "" - -[staff] -names = [] - -[guest] -file = "config/guests.txt" - -[notice] -missing_count = 0 - -[ocr.crop] -left = 0.05 -top = 0.35 -right = 0.95 -bottom = 0.95 - -[ocr] -preprocess = true -scale = 1.5 -use_angle_cls = false diff --git a/VRWT_Tool/VRC_OSC/config/guests.example.txt b/VRWT_Tool/VRC_OSC/config/guests.example.txt deleted file mode 100644 index 3124eb2..0000000 --- a/VRWT_Tool/VRC_OSC/config/guests.example.txt +++ /dev/null @@ -1,3 +0,0 @@ -GuestUserA -GuestUserB -GuestUserC diff --git a/VRWT_Tool/VRC_OSC/config/secrets.example.toml b/VRWT_Tool/VRC_OSC/config/secrets.example.toml deleted file mode 100644 index 8cc68c2..0000000 --- a/VRWT_Tool/VRC_OSC/config/secrets.example.toml +++ /dev/null @@ -1,6 +0,0 @@ -[discord] -webhook_url = "" - -[vrchat] -username = "" -password = "" diff --git a/VRWT_Tool/VRC_OSC/go.mod b/VRWT_Tool/VRC_OSC/go.mod deleted file mode 100644 index ea7c930..0000000 --- a/VRWT_Tool/VRC_OSC/go.mod +++ /dev/null @@ -1,4 +0,0 @@ -module vrc_osc_go - -go 1.22 - diff --git a/VRWT_Tool/VRC_OSC/internal/app/app.go b/VRWT_Tool/VRC_OSC/internal/app/app.go deleted file mode 100644 index 5960fb1..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/app.go +++ /dev/null @@ -1,75 +0,0 @@ -package app - -import ( - "log" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "vrc_osc_go/internal/config" - "vrc_osc_go/internal/consenttool" - "vrc_osc_go/internal/osc" -) - -type discordMuteState struct { - mu sync.Mutex - muted bool - buttonPressed bool - lastAction time.Time -} - -func Run(configPath string, mode string) error { - if mode != "" { - return consenttool.Run(configPath, mode) - } - - cfg, err := config.Load(configPath) - if err != nil { - return err - } - InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames)) - 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) - discordState := &discordMuteState{muted: true} - SetDiscordMuted(true) - server.Map("DiscordSend", func(_ string, args []osc.Value) error { - log.Printf("received DiscordSend args=%+v", args) - if err := handleDiscordSend(discordState, args); err != nil { - log.Printf("discord mute failed: %v", err) - return err - } - SetDiscordMuted(discordState.muted) - return nil - }) - server.Map("ocrEnabled", func(_ string, args []osc.Value) error { - log.Printf("received ocrEnabled args=%+v", args) - if err := runPythonOcrFromScreen(); err != nil { - log.Printf("ocr failed: %v", err) - return err - } - return nil - }) - - errCh := make(chan error, 1) - go func() { - log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port) - errCh <- server.Serve() - }() - go watchVrchatLog() - startSelfMonitor(cfg, discordState) - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - select { - case err := <-errCh: - log.Printf("osc server stopped: %v", err) - return err - case <-sigCh: - log.Printf("shutdown signal received") - server.Close() - return nil - } -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord.go b/VRWT_Tool/VRC_OSC/internal/app/discord.go deleted file mode 100644 index 39bd677..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/discord.go +++ /dev/null @@ -1,84 +0,0 @@ -package app - -import ( - "fmt" - "log" - "os/exec" -) - -func pressDiscordMuteHotkey() error { - script := ` -$ErrorActionPreference = "Stop" - -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 -} - -function Get-BestDiscordWindow { - $windows = Get-DiscordWindows - if (-not $windows -or $windows.Count -eq 0) { return $null } - - $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 - - return $ranked | Select-Object -First 1 -} - -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 -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go deleted file mode 100644 index 7165d9d..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go +++ /dev/null @@ -1,89 +0,0 @@ -package app - -import ( - "fmt" - "log" - "strings" - "time" - - "vrc_osc_go/internal/osc" -) - -const discordDebounce = 300 * time.Millisecond - -func handleDiscordSend(state *discordMuteState, args []osc.Value) error { - state.mu.Lock() - defer state.mu.Unlock() - - if len(args) == 0 { - return fmt.Errorf("OSC args is empty") - } - isPressed, err := toBool(args[0]) - if err != nil { - return err - } - - now := time.Now() - if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce { - return nil - } - - if isPressed && !state.buttonPressed { - state.buttonPressed = true - if state.muted { - log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey") - if err := pressDiscordMuteHotkey(); err != nil { - return err - } - state.muted = false - SetDiscordMuted(state.muted) - state.lastAction = now - } else { - log.Printf("INFO already unmuted") - } - return nil - } - - if !isPressed && state.buttonPressed { - state.buttonPressed = false - if !state.muted { - log.Printf("ACTION VRChat button released -> Discord mute hotkey") - if err := pressDiscordMuteHotkey(); err != nil { - return err - } - state.muted = true - SetDiscordMuted(state.muted) - state.lastAction = now - } else { - log.Printf("INFO already muted") - } - } - - return nil -} - -func toBool(v osc.Value) (bool, error) { - switch v.Type { - case 'T': - return true, nil - case 'F': - return false, nil - case 'i': - return v.Int != 0, nil - case 'f': - return v.Float != 0, nil - case 's': - switch strings.ToLower(strings.TrimSpace(v.Str)) { - case "true", "1", "on", "yes": - return true, nil - case "false", "0", "off", "no": - return false, nil - default: - return false, fmt.Errorf("unsupported OSC value: %q", v.Str) - } - case 0: - return false, fmt.Errorf("unsupported OSC value") - default: - return false, fmt.Errorf("unsupported OSC type: %q", v.Type) - } -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/guest_status.go b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go deleted file mode 100644 index 15139fe..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/guest_status.go +++ /dev/null @@ -1,136 +0,0 @@ -package app - -import ( - "fmt" - "encoding/json" - "os" - "path/filepath" - "sort" - "sync" - "time" - - "vrc_osc_go/internal/common" -) - -type GuestStatus struct { - Name string `json:"name"` - Present bool `json:"present"` - LastJoin time.Time `json:"last_join"` - LastLeave time.Time `json:"last_leave"` - AbsentFor string `json:"absent_for"` -} - -type GuestTracker struct { - mu sync.RWMutex - current string - items map[string]*GuestStatus -} - -func NewGuestTracker(names []string) *GuestTracker { - items := make(map[string]*GuestStatus, len(names)) - for _, name := range names { - items[name] = &GuestStatus{Name: name} - } - return &GuestTracker{items: items} -} - -func (t *GuestTracker) SetCurrentInstance(label string) { - t.mu.Lock() - defer t.mu.Unlock() - t.current = label -} - -func (t *GuestTracker) CurrentInstance() string { - t.mu.RLock() - defer t.mu.RUnlock() - return t.current -} - -func (t *GuestTracker) MarkJoin(name string, at time.Time) { - t.mu.Lock() - defer t.mu.Unlock() - s := t.ensure(name) - s.Present = true - s.LastJoin = at - s.AbsentFor = "" - _ = t.persistLocked() -} - -func (t *GuestTracker) MarkLeave(name string, at time.Time) { - t.mu.Lock() - defer t.mu.Unlock() - s := t.ensure(name) - s.Present = false - s.LastLeave = at - if !s.LastJoin.IsZero() { - s.AbsentFor = humanSince(at) - } - _ = t.persistLocked() -} - -func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus { - t.mu.RLock() - defer t.mu.RUnlock() - out := make([]GuestStatus, 0, len(t.items)) - for _, s := range t.items { - cp := *s - if !cp.Present && !cp.LastLeave.IsZero() { - cp.AbsentFor = humanSince(cp.LastLeave, now) - } - out = append(out, cp) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -func (t *GuestTracker) persistLocked() error { - dir := filepath.Join(common.RootDir(), "runtime") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - snapshot := make([]GuestStatus, 0, len(t.items)) - for _, s := range t.items { - cp := *s - snapshot = append(snapshot, cp) - } - f, err := os.Create(filepath.Join(dir, "guest_snapshot.json")) - if err != nil { - return err - } - defer f.Close() - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - return enc.Encode(snapshot) -} - -func (t *GuestTracker) ensure(name string) *GuestStatus { - if s, ok := t.items[name]; ok { - return s - } - s := &GuestStatus{Name: name} - t.items[name] = s - return s -} - -func humanSince(at time.Time, now ...time.Time) string { - ref := time.Now() - if len(now) > 0 { - ref = now[0] - } - if at.IsZero() { - return "" - } - d := ref.Sub(at) - if d < time.Minute { - return "1分未満" - } - m := int(d.Minutes()) - if m < 60 { - return fmt.Sprintf("%d分前", m) - } - h := m / 60 - if h < 24 { - return fmt.Sprintf("%d時間前", h) - } - return fmt.Sprintf("%d日前", h/24) -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/ocr.go b/VRWT_Tool/VRC_OSC/internal/app/ocr.go deleted file mode 100644 index 6f6e2ca..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/ocr.go +++ /dev/null @@ -1,30 +0,0 @@ -package app - -import ( - "fmt" - "log" - "os/exec" -) - -func runPythonOcrFromScreen() error { - script := ` -import sys -from pathlib import Path -root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC") -sys.path.insert(0, str(root / "src")) -from ocr.ocr_actions import runOcrFromScreen -runOcrFromScreen() -` - cmd := exec.Command("python", "-c", script) - cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC` - out, err := cmd.CombinedOutput() - if len(out) > 0 { - log.Printf("ocr output: %s", string(out)) - SetOCRText(string(out)) - SetTranslateText("翻訳: 未実装") - } - if err != nil { - return fmt.Errorf("ocr failed: %w", err) - } - return nil -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/runtime.go b/VRWT_Tool/VRC_OSC/internal/app/runtime.go deleted file mode 100644 index cc4df2b..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/runtime.go +++ /dev/null @@ -1,48 +0,0 @@ -package app - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "vrc_osc_go/internal/common" -) - -func appendRuntimeLog(title, text string) error { - dir := filepath.Join(common.RootDir(), "runtime") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - path := filepath.Join(dir, "runtime.log") - body := text - 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 -} - -func appendJoinLeaveLog(text string) error { - dir := filepath.Join(common.RootDir(), "runtime") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - path := filepath.Join(dir, "join_leave.log") - body := text - 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 -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go b/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go deleted file mode 100644 index 6ac10a6..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go +++ /dev/null @@ -1,69 +0,0 @@ -package app - -import ( - "log" - "os" - "regexp" - "strings" - "time" - - "vrc_osc_go/internal/config" - "vrc_osc_go/internal/osc" -) - -var ( - selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) - selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) -) - -func startSelfMonitor(cfg *config.Config, state *discordMuteState) { - if cfg.VrcLog.SelfName == "" { - return - } - go func() { - lastState := "" - for { - path, err := findLatestVrchatLog() - if err != nil { - time.Sleep(2 * time.Second) - continue - } - text, err := os.ReadFile(path) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - nextState := extractLatestSelfLogState(string(text), cfg.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) - } - } 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) - } - } - lastState = nextState - } - time.Sleep(2 * time.Second) - } - }() -} - -func extractLatestSelfLogState(text, selfName string) string { - if selfName == "" { - return "" - } - state := "" - for _, rawLine := range strings.Split(text, "\n") { - if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName { - state = "joined" - continue - } - if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName { - state = "left" - } - } - return state -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/state.go b/VRWT_Tool/VRC_OSC/internal/app/state.go deleted file mode 100644 index 4240c98..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/state.go +++ /dev/null @@ -1,89 +0,0 @@ -package app - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - "time" - - "vrc_osc_go/internal/common" -) - -type RuntimeState struct { - mu sync.RWMutex - DiscordMuted bool - LastOCRText string - LastTranslate string - CurrentWorld string -} - -var runtimeState = &RuntimeState{} -var guestTracker *GuestTracker - -func SetGuestTracker(t *GuestTracker) { guestTracker = t } -func InitGuestTracker(t *GuestTracker) { - guestTracker = t - if guestTracker != nil { - guestTracker.mu.Lock() - _ = guestTracker.persistLocked() - guestTracker.mu.Unlock() - } -} -func GetGuestTracker() *GuestTracker { return guestTracker } - -func SetDiscordMuted(v bool) { - runtimeState.mu.Lock() - runtimeState.DiscordMuted = v - runtimeState.mu.Unlock() - _ = persistRuntimeState() -} - -func SetOCRText(v string) { - runtimeState.mu.Lock() - runtimeState.LastOCRText = v - runtimeState.mu.Unlock() - _ = persistRuntimeState() -} - -func SetTranslateText(v string) { - runtimeState.mu.Lock() - runtimeState.LastTranslate = v - runtimeState.mu.Unlock() - _ = persistRuntimeState() -} - -func SetCurrentWorld(v string) { - runtimeState.mu.Lock() - runtimeState.CurrentWorld = v - runtimeState.mu.Unlock() - _ = persistRuntimeState() -} - -func SnapshotRuntime() RuntimeState { - runtimeState.mu.RLock() - defer runtimeState.mu.RUnlock() - return *runtimeState -} - -func persistRuntimeState() error { - s := SnapshotRuntime() - dir := filepath.Join(common.RootDir(), "runtime") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - f, err := os.Create(filepath.Join(dir, "state.json")) - if err != nil { - return err - } - defer f.Close() - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - return enc.Encode(map[string]any{ - "discord_muted": s.DiscordMuted, - "ocr": s.LastOCRText, - "translate": s.LastTranslate, - "world": s.CurrentWorld, - "updated_at": time.Now().Format(time.RFC3339), - }) -} diff --git a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go deleted file mode 100644 index 9a1fdf8..0000000 --- a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go +++ /dev/null @@ -1,345 +0,0 @@ -package app - -import ( - "bufio" - "fmt" - "log" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -var ( - joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) - leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) - worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`) - instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`) - worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`) - worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`) - worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`) - enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`) -) - -type vrcLogState struct { - location string - worldID string - instanceID string - worldName string - pendingWorldName string - presentSet map[string]struct{} - initialized bool -} - -func getVrchatLogDir() (string, error) { - userProfile := os.Getenv("USERPROFILE") - if userProfile == "" { - return "", os.ErrNotExist - } - return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil -} - -func findLatestVrchatLog() (string, error) { - dir, err := getVrchatLogDir() - if err != nil { - return "", err - } - - entries, err := os.ReadDir(dir) - if err != nil { - return "", err - } - - var latest string - var latestMod time.Time - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - if info.ModTime().After(latestMod) { - latestMod = info.ModTime() - latest = filepath.Join(dir, name) - } - } - if latest == "" { - return "", os.ErrNotExist - } - return latest, nil -} - -func watchVrchatLog() { - lastPath := "" - lastSize := int64(0) - state := &vrcLogState{ - presentSet: map[string]struct{}{}, - } - for { - path, err := findLatestVrchatLog() - if err != nil { - time.Sleep(2 * time.Second) - continue - } - info, err := os.Stat(path) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - if path != lastPath { - log.Printf("vrchat log watching %s", path) - lastPath = path - lastSize = 0 - if err := scanExistingVrchatLog(path, state); err != nil { - log.Printf("vrchat log initial scan failed: %v", err) - } - if info2, err := os.Stat(path); err == nil { - lastSize = info2.Size() - } else { - lastSize = info.Size() - } - time.Sleep(2 * time.Second) - continue - } - - if info.Size() < lastSize { - lastSize = 0 - } - if info.Size() > lastSize { - f, err := os.Open(path) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - _, _ = f.Seek(lastSize, 0) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if err := appendRuntimeLog("VRC LOG", line); err != nil { - log.Printf("append runtime log failed: %v", err) - } - 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) - } - _ = appendRuntimeLog("VRC WORLD", worldLabel) - } - at := extractLineTime(line) - if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { - name := strings.TrimSpace(m[1]) - state.presentSet[name] = struct{}{} - if tracker := GetGuestTracker(); tracker != nil { - tracker.MarkJoin(name, at) - } - out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) - log.Print(out) - _ = appendJoinLeaveLog(out) - continue - } - if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { - name := strings.TrimSpace(m[1]) - delete(state.presentSet, name) - if tracker := GetGuestTracker(); tracker != nil { - tracker.MarkLeave(name, at) - } - out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) - log.Print(out) - _ = appendJoinLeaveLog(out) - continue - } - } - lastSize = info.Size() - _ = f.Close() - } - time.Sleep(2 * time.Second) - } -} - -func scanExistingVrchatLog(path string, state *vrcLogState) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - state.presentSet = map[string]struct{}{} - state.location = "" - state.worldID = "" - state.instanceID = "" - state.worldName = "" - state.pendingWorldName = "" - state.initialized = false - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if changed, _ := updateWorldState(state, line); changed { - state.presentSet = map[string]struct{}{} - } - if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { - name := strings.TrimSpace(m[1]) - state.presentSet[name] = struct{}{} - if tracker := GetGuestTracker(); tracker != nil { - tracker.MarkJoin(name, extractLineTime(line)) - } - continue - } - if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { - name := strings.TrimSpace(m[1]) - delete(state.presentSet, name) - if tracker := GetGuestTracker(); tracker != nil { - tracker.MarkLeave(name, extractLineTime(line)) - } - continue - } - } - if err := scanner.Err(); err != nil { - return err - } - 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) - } - _ = appendRuntimeLog("VRC WORLD", state.worldName) - } - return nil -} - -func updateWorldState(state *vrcLogState, line string) (bool, string) { - worldID := "" - instanceID := "" - worldName := "" - roomTitle := "" - - if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 { - roomTitle = strings.TrimSpace(m[1]) - state.pendingWorldName = roomTitle - } - if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 { - worldID = strings.TrimSpace(m[1]) - instanceID = strings.TrimSpace(m[2]) - } - - if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 { - worldID = strings.TrimSpace(m[1]) - } - if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 { - instanceID = strings.TrimSpace(m[1]) - } - if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 { - worldName = strings.TrimSpace(m[1]) - } - - if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" { - if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 { - worldID = worldMatch[1] - } - } - - nextLocation := "" - if worldID != "" { - nextLocation = worldID - if instanceID != "" { - nextLocation = worldID + ":" + instanceID - } - } - - if nextLocation != "" && nextLocation != state.location { - state.location = nextLocation - state.worldID = worldID - state.instanceID = instanceID - if worldName != "" { - state.worldName = worldName - } else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { - state.worldName = roomTitle - } - label := state.worldName - if label == "" { - label = state.worldID - } - if label == "" { - label = nextLocation - } - if state.initialized { - log.Printf("world changed: %s", label) - _ = appendRuntimeLog("VRC WORLD", label) - } - return true, label - } - - if worldName != "" { - state.worldName = worldName - state.pendingWorldName = "" - return false, "" - } - if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { - state.worldName = roomTitle - } - - return false, "" -} - -func shouldUseRoomTitle(roomTitle, currentWorldName string) bool { - if roomTitle == "" { - return false - } - if strings.Contains(roomTitle, "Home Location") { - return false - } - if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle { - return false - } - if currentWorldName == "" { - return true - } - if roomTitle == currentWorldName { - return false - } - if len(roomTitle) > len(currentWorldName) { - return true - } - if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") { - return true - } - return false -} - -func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool { - if roomTitle == "" { - return false - } - if strings.Contains(roomTitle, "Home Location") { - return false - } - if currentWorldName == "" { - return true - } - if roomTitle == currentWorldName { - return false - } - return true -} - -func extractLineTime(line string) time.Time { - if len(line) < 19 { - return time.Now() - } - t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local) - if err == nil { - return t - } - return time.Now() -} diff --git a/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go deleted file mode 100644 index f63e477..0000000 --- a/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go +++ /dev/null @@ -1,6 +0,0 @@ -package buildinfo - -var ( - Version = "dev" - BuildTime = "unknown" -) diff --git a/VRWT_Tool/VRC_OSC/internal/common/project_paths.go b/VRWT_Tool/VRC_OSC/internal/common/project_paths.go deleted file mode 100644 index 76b587c..0000000 --- a/VRWT_Tool/VRC_OSC/internal/common/project_paths.go +++ /dev/null @@ -1,16 +0,0 @@ -package common - -import ( - "os" - "path/filepath" -) - -func RuntimeBaseDir() string { - exe, err := os.Executable() - if err == nil { - return filepath.Dir(exe) - } - return "." -} - -func RootDir() string { return RuntimeBaseDir() } diff --git a/VRWT_Tool/VRC_OSC/internal/config/config.go b/VRWT_Tool/VRC_OSC/internal/config/config.go deleted file mode 100644 index e584686..0000000 --- a/VRWT_Tool/VRC_OSC/internal/config/config.go +++ /dev/null @@ -1,146 +0,0 @@ -package config - -import ( - "bufio" - "os" - "path/filepath" - "strconv" - "strings" - - "vrc_osc_go/internal/common" -) - -type Config struct { - OSC OSCConfig - VrcLog VrcLogConfig -} - -type OSCConfig struct { - Host string - Port int -} - -type VrcLogConfig struct { - SelfName string - GuestNames []string - GuestFile string - StaffNames []string - MissingCount int - LogPatterns []string -} - -func Load(path string) (*Config, error) { - cfg := &Config{ - OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, - } - if path == "" { - path = filepath.Join(common.RootDir(), "config", "config.toml") - } else if !filepath.IsAbs(path) { - path = filepath.Join(common.RootDir(), path) - } - file, err := os.Open(path) - if err != nil { - return cfg, nil - } - defer file.Close() - - var section string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - line = strings.TrimPrefix(line, "\ufeff") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - section = strings.Trim(line, "[]") - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, "\"'") - switch section { - case "osc": - switch key { - case "host": - cfg.OSC.Host = val - case "port": - if n, err := strconv.Atoi(val); err == nil { - cfg.OSC.Port = n - } - } - case "self": - if key == "name" { - cfg.VrcLog.SelfName = val - } - case "notice": - if key == "missing_count" { - if n, err := strconv.Atoi(val); err == nil { - cfg.VrcLog.MissingCount = n - } - } - case "vrc_log": - if key == "patterns" { - cfg.VrcLog.LogPatterns = parseList(val) - } - case "staff": - if key == "names" { - cfg.VrcLog.StaffNames = parseList(val) - } - case "guest": - switch key { - case "names": - cfg.VrcLog.GuestNames = parseList(val) - case "file": - cfg.VrcLog.GuestFile = val - } - } - } - if cfg.VrcLog.GuestFile != "" { - if !filepath.IsAbs(cfg.VrcLog.GuestFile) { - cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) - } - if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil { - cfg.VrcLog.GuestNames = names - } - } - return cfg, scanner.Err() -} - -func parseList(value string) []string { - value = strings.TrimSpace(value) - value = strings.TrimPrefix(value, "[") - value = strings.TrimSuffix(value, "]") - if value == "" { - return nil - } - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(strings.Trim(part, "\"'")) - if part != "" { - out = append(out, part) - } - } - return out -} - -func loadLines(path string) ([]string, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - lines := strings.Split(string(data), "\n") - out := make([]string, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) - if line != "" && !strings.HasPrefix(line, "#") { - out = append(out, line) - } - } - return out, nil -} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/config.go b/VRWT_Tool/VRC_OSC/internal/consenttool/config.go deleted file mode 100644 index 702f2ca..0000000 --- a/VRWT_Tool/VRC_OSC/internal/consenttool/config.go +++ /dev/null @@ -1,144 +0,0 @@ -package consenttool - -import ( - "bufio" - "os" - "path/filepath" - "strconv" - "strings" -) - -type Config struct { - Consent ConsentConfig - Log LogConfig -} - -type ConsentConfig struct { - InputCSV string - OutputJSON string - DisplayFields []string - ConsentFields []string - ConsentValues []string - NormalizeCase bool - AllowDuplicates bool -} - -type LogConfig struct { - InputPath string - OutputPath string - IncludeRegex string - KeepNonMatch bool -} - -func LoadConfig(path string) (*Config, error) { - cfg := &Config{ - Consent: ConsentConfig{ - InputCSV: "config/consent.csv", - OutputJSON: "config/consent_list.json", - DisplayFields: []string{"display_name", "vrchat_name", "name"}, - ConsentFields: []string{"consent", "agree", "agreed", "checked"}, - ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"}, - NormalizeCase: false, - AllowDuplicates: false, - }, - Log: LogConfig{ - InputPath: "runtime/output_log.txt", - OutputPath: "runtime/research_log.txt", - IncludeRegex: `\[[A-Z0-9:_ -]+\]`, - KeepNonMatch: false, - }, - } - - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - var section string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - line = strings.TrimPrefix(line, "\ufeff") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - section = strings.Trim(line, "[]") - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, "\"'") - - switch section { - case "consent": - switch key { - case "input_csv": - cfg.Consent.InputCSV = val - case "output_json": - cfg.Consent.OutputJSON = val - case "display_fields": - cfg.Consent.DisplayFields = parseList(val) - case "consent_fields": - cfg.Consent.ConsentFields = parseList(val) - case "consent_values": - cfg.Consent.ConsentValues = parseList(val) - case "normalize_case": - cfg.Consent.NormalizeCase = parseBool(val) - case "allow_duplicates": - cfg.Consent.AllowDuplicates = parseBool(val) - } - case "log": - switch key { - case "input_path": - cfg.Log.InputPath = val - case "output_path": - cfg.Log.OutputPath = val - case "include_regex": - cfg.Log.IncludeRegex = val - case "keep_non_match": - cfg.Log.KeepNonMatch = parseBool(val) - } - } - } - return cfg, scanner.Err() -} - -func parseList(value string) []string { - value = strings.TrimSpace(value) - value = strings.TrimPrefix(value, "[") - value = strings.TrimSuffix(value, "]") - if value == "" { - return nil - } - items := strings.Split(value, ",") - out := make([]string, 0, len(items)) - for _, item := range items { - item = strings.TrimSpace(item) - item = strings.Trim(item, "\"'") - if item != "" { - out = append(out, item) - } - } - return out -} - -func parseBool(value string) bool { - n, err := strconv.ParseBool(strings.TrimSpace(value)) - return err == nil && n -} - -func resolvePath(baseDir, value string) string { - if value == "" { - return "" - } - if filepath.IsAbs(value) { - return value - } - return filepath.Clean(filepath.Join(baseDir, value)) -} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go deleted file mode 100644 index 1a58dd2..0000000 --- a/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go +++ /dev/null @@ -1,183 +0,0 @@ -package consenttool - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strings" -) - -func Run(configPath string, mode string) error { - cfg, err := LoadConfig(configPath) - if err != nil { - return err - } - baseDir := filepath.Dir(configPath) - - switch mode { - case "generate-json": - return generateJSON(cfg, baseDir) - case "extract-log": - return extractLog(cfg, baseDir) - default: - return fmt.Errorf("unknown mode %q", mode) - } -} - -func generateJSON(cfg *Config, baseDir string) error { - inputPath := resolvePath(baseDir, cfg.Consent.InputCSV) - outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON) - - rows, err := readCSVRows(inputPath) - if err != nil { - return err - } - - names := collectConsentedNames(rows, cfg.Consent) - body, err := json.MarshalIndent(names, "", " ") - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return err - } - return os.WriteFile(outputPath, append(body, '\n'), 0o644) -} - -func extractLog(cfg *Config, baseDir string) error { - inputPath := resolvePath(baseDir, cfg.Log.InputPath) - outputPath := resolvePath(baseDir, cfg.Log.OutputPath) - - data, err := os.ReadFile(inputPath) - if err != nil { - return err - } - - re, err := regexp.Compile(cfg.Log.IncludeRegex) - if err != nil { - return err - } - - var kept []string - for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { - if re.MatchString(line) { - kept = append(kept, line) - continue - } - if cfg.Log.KeepNonMatch { - kept = append(kept, line) - } - } - - body := strings.Join(kept, "\n") - if body != "" { - body += "\n" - } - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return err - } - return os.WriteFile(outputPath, []byte(body), 0o644) -} - -func readCSVRows(path string) ([]map[string]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - r := csv.NewReader(file) - headers, err := r.Read() - if err != nil { - return nil, err - } - for i, header := range headers { - headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff") - } - - var rows []map[string]string - for { - record, err := r.Read() - if err != nil { - if err == io.EOF { - break - } - return nil, err - } - row := make(map[string]string, len(headers)) - for i, header := range headers { - if i < len(record) { - row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff") - } - } - rows = append(rows, row) - } - return rows, nil -} - -func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string { - displayFields := cfg.DisplayFields - if len(displayFields) == 0 { - displayFields = []string{"display_name", "vrchat_name", "name"} - } - consentFields := cfg.ConsentFields - if len(consentFields) == 0 { - consentFields = []string{"consent", "agree", "agreed", "checked"} - } - accepted := make(map[string]struct{}) - var names []string - - for _, row := range rows { - if !rowIsConsented(row, consentFields, cfg.ConsentValues) { - continue - } - name := firstNonEmpty(row, displayFields) - if name == "" { - continue - } - key := name - if cfg.NormalizeCase { - key = strings.ToLower(key) - } - if !cfg.AllowDuplicates { - if _, ok := accepted[key]; ok { - continue - } - accepted[key] = struct{}{} - } - names = append(names, name) - } - return names -} - -func rowIsConsented(row map[string]string, fields []string, values []string) bool { - if len(values) == 0 { - values = []string{"1", "true", "yes", "y", "on", "checked"} - } - allowed := make(map[string]struct{}, len(values)) - for _, v := range values { - allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{} - } - for _, field := range fields { - if v, ok := row[field]; ok { - if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok { - return true - } - } - } - return false -} - -func firstNonEmpty(row map[string]string, fields []string) string { - for _, field := range fields { - if v := strings.TrimSpace(row[field]); v != "" { - return v - } - } - return "" -} diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go deleted file mode 100644 index a7ef4b6..0000000 --- a/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package consenttool - -import ( - "os" - "path/filepath" - "testing" -) - -func TestCollectConsentedNames(t *testing.T) { - rows := []map[string]string{ - {"display_name": "Alice", "consent": "true"}, - {"display_name": "Bob", "consent": "false"}, - {"display_name": "Alice", "consent": "yes"}, - } - - names := collectConsentedNames(rows, ConsentConfig{}) - if len(names) != 1 || names[0] != "Alice" { - t.Fatalf("unexpected names: %#v", names) - } -} - -func TestExtractLog(t *testing.T) { - dir := t.TempDir() - cfg := &Config{ - Log: LogConfig{ - InputPath: "input.log", - OutputPath: "out.log", - IncludeRegex: `ID:[0-9]+`, - }, - } - if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if err := extractLog(cfg, dir); err != nil { - t.Fatalf("extractLog: %v", err) - } - out, err := os.ReadFile(filepath.Join(dir, "out.log")) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if string(out) != "ID:1 hello\n" { - t.Fatalf("unexpected output: %q", string(out)) - } -} diff --git a/VRWT_Tool/VRC_OSC/internal/osc/server.go b/VRWT_Tool/VRC_OSC/internal/osc/server.go deleted file mode 100644 index 2488509..0000000 --- a/VRWT_Tool/VRC_OSC/internal/osc/server.go +++ /dev/null @@ -1,141 +0,0 @@ -package osc - -import ( - "bytes" - "encoding/binary" - "fmt" - "math" - "net" - "strings" - "sync" -) - -type Value struct { - Type byte - Float float32 - Int int32 - Bool bool - Str string -} - -type Handler func(address string, args []Value) error - -type Server struct { - addr string - conn *net.UDPConn - handlers map[string]Handler - mu sync.RWMutex -} - -func NewServer(host string, port int) *Server { - return &Server{ - addr: fmt.Sprintf("%s:%d", host, port), - handlers: map[string]Handler{}, - } -} - -func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h } -func (s *Server) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil -} - -func (s *Server) Serve() error { - addr, err := net.ResolveUDPAddr("udp", s.addr) - if err != nil { - return err - } - conn, err := net.ListenUDP("udp", addr) - if err != nil { - return err - } - s.conn = conn - buf := make([]byte, 2048) - for { - n, _, err := conn.ReadFromUDP(buf) - if err != nil { - return err - } - a, v, err := parseMessage(buf[:n]) - if err != nil { - continue - } - s.mu.RLock() - h := s.handlers[a] - s.mu.RUnlock() - if h != nil { - _ = h(a, v) - } - } -} - -func parseMessage(b []byte) (string, []Value, error) { - address, offset, err := parsePaddedString(b, 0) - if err != nil { - return "", nil, err - } - if !strings.HasPrefix(address, "/") { - return "", nil, fmt.Errorf("invalid address") - } - if offset >= len(b) { - return address, nil, nil - } - typetags, offset, err := parsePaddedString(b, offset) - if err != nil { - return "", nil, err - } - if !strings.HasPrefix(typetags, ",") { - return address, nil, nil - } - var values []Value - for _, tag := range typetags[1:] { - switch tag { - case 'i': - if offset+4 > len(b) { - return "", nil, fmt.Errorf("invalid int") - } - values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))}) - offset += 4 - case 'f': - if offset+4 > len(b) { - return "", nil, fmt.Errorf("invalid float") - } - bits := binary.BigEndian.Uint32(b[offset : offset+4]) - values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)}) - offset += 4 - case 's': - s, next, err := parsePaddedString(b, offset) - if err != nil { - return "", nil, err - } - values = append(values, Value{Type: 's', Str: s}) - offset = next - case 'T': - values = append(values, Value{Type: 'T', Bool: true}) - case 'F': - values = append(values, Value{Type: 'F', Bool: false}) - } - } - return address, values, nil -} - -func parsePaddedString(b []byte, offset int) (string, int, error) { - if offset >= len(b) { - return "", offset, fmt.Errorf("invalid osc string") - } - end := bytes.IndexByte(b[offset:], 0) - if end < 0 { - return "", offset, fmt.Errorf("invalid osc string") - } - s := string(b[offset : offset+end]) - next := offset + end + 1 - for next%4 != 0 { - next++ - } - if next > len(b) { - next = len(b) - } - return s, next, nil -} diff --git a/VRWT_Tool/VRC_OSC/internal/update/manager.go b/VRWT_Tool/VRC_OSC/internal/update/manager.go deleted file mode 100644 index e377fab..0000000 --- a/VRWT_Tool/VRC_OSC/internal/update/manager.go +++ /dev/null @@ -1,106 +0,0 @@ -package update - -import ( - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "runtime" - - "vrc_osc_go/internal/buildinfo" - "vrc_osc_go/internal/common" -) - -type Manager struct { - Client *Client - OwnerRepo string - AssetName string - CurrentLabel string -} - -func (m *Manager) CheckAndUpdate() (bool, error) { - if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" { - return false, nil - } - latest, err := m.Client.LatestRelease(m.OwnerRepo) - if err != nil { - return false, err - } - if latest.TagName == "" || latest.TagName == m.CurrentLabel { - return false, nil - } - assetURL := "" - for _, a := range latest.Assets { - if a.Name == m.AssetName { - assetURL = a.URL - break - } - } - if assetURL == "" { - return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName) - } - base := common.RootDir() - tmpDir := filepath.Join(base, "update", latest.TagName) - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return false, err - } - zipPath := filepath.Join(tmpDir, m.AssetName) - log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName) - if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil { - return false, err - } - if err := ExtractZip(zipPath, tmpDir); err != nil { - return false, err - } - exePath := filepath.Join(base, CurrentExeName()) - newExe := filepath.Join(tmpDir, CurrentExeName()) - if _, err := os.Stat(newExe); err != nil { - return false, err - } - backup := exePath + ".bak" - _ = os.Remove(backup) - if err := os.Rename(exePath, backup); err != nil { - return false, err - } - if err := CopyFile(newExe, exePath); err != nil { - _ = os.Rename(backup, exePath) - return false, err - } - _ = os.Remove(backup) - return true, nil -} - -func CopyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, in) - return err -} - -func Launch(exe string, args []string) error { - cmd := exec.Command(exe, args...) - cmd.Dir = filepath.Dir(exe) - return cmd.Start() -} - -func Version() string { return buildinfo.Version } - -func CurrentExeName() string { - if runtime.GOOS == "windows" { - return "vrc_osc.exe" - } - return "vrc_osc" -} diff --git a/VRWT_Tool/VRC_OSC/internal/update/release.go b/VRWT_Tool/VRC_OSC/internal/update/release.go deleted file mode 100644 index 8fff9f1..0000000 --- a/VRWT_Tool/VRC_OSC/internal/update/release.go +++ /dev/null @@ -1,125 +0,0 @@ -package update - -import ( - "archive/zip" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" -) - -type ReleaseInfo struct { - TagName string `json:"tag_name"` - Assets []struct { - Name string `json:"name"` - URL string `json:"browser_download_url"` - } `json:"assets"` -} - -type Client struct { - BaseURL string - HTTP *http.Client -} - -func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) { - req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil) - if err != nil { - return nil, err - } - var info ReleaseInfo - if err := c.doJSON(req, &info); err != nil { - return nil, err - } - return &info, nil -} - -func (c *Client) DownloadAsset(url, dst string) error { - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := c.httpClient().Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed: %s", resp.Status) - } - f, err := os.Create(dst) - if err != nil { - return err - } - defer f.Close() - _, err = io.Copy(f, resp.Body) - return err -} - -func ExtractZip(zipPath, dstDir string) error { - zr, err := zip.OpenReader(zipPath) - if err != nil { - return err - } - defer zr.Close() - root := filepath.Clean(dstDir) + string(os.PathSeparator) - for _, f := range zr.File { - target := filepath.Clean(filepath.Join(dstDir, f.Name)) - if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) { - return fmt.Errorf("zip path escapes destination: %s", f.Name) - } - if f.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { - return err - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - rc, err := f.Open() - if err != nil { - return err - } - out, err := os.Create(target) - if err != nil { - rc.Close() - return err - } - _, copyErr := io.Copy(out, rc) - out.Close() - rc.Close() - if copyErr != nil { - return copyErr - } - } - return nil -} - -func (c *Client) apiURL(ownerRepo, suffix string) string { - base := strings.TrimRight(c.BaseURL, "/") - return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix -} - -func (c *Client) doJSON(req *http.Request, out any) error { - resp, err := c.httpClient().Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) - } - return json.NewDecoder(resp.Body).Decode(out) -} - -func (c *Client) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - return &http.Client{Timeout: 30 * time.Second} -} diff --git a/VRWT_Tool/VRC_OSC/readme.txt b/VRWT_Tool/VRC_OSC/readme.txt deleted file mode 100644 index ad91257..0000000 --- a/VRWT_Tool/VRC_OSC/readme.txt +++ /dev/null @@ -1,238 +0,0 @@ -# VRC_OSC - -VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。 - -## 起動 - -依存関係を入れる。 - -```powershell -python -m pip install -r requirements.txt -``` - -PowerShell から起動する。 - -```powershell -bin\vrc_osc.ps1 -``` - -Python から直接起動する場合。 - -```powershell -python src\bin\vrc_osc.py -``` - -OSC は `127.0.0.1:9001` で待ち受ける。 - -## 構成 - -```text -bin/ PowerShell 起動スクリプト -config/ example とローカル設定置き場 -src/app.py アプリ本体のエントリ -src/bin/ Python 起動スクリプト -src/common/ パス、設定ロード、runtime ログなどの共通基盤 -src/discord_control/ Discord ウィンドウ操作 -src/ocr/ OCR 実行 -src/osc/ OSC gateway -src/screen/ VRChat ウィンドウ検出とキャプチャ -src/tools/ 単体実行用の補助ツール -src/translate/ 翻訳 -src/vrc_log/ VRChat ログ解析と名簿照合 -``` - -## 設定ファイル - -実設定は `config/config.toml` に置く。このファイルは Git に入れない。 - -雛形は `config/config.example.toml`。 - -```toml -[self] -name = "自分のVRChat表示名" - -[staff] -names = [ - "StaffUserA" -] - -[guest] -file = "config/guests.txt" - -[notice] -missing_count = 3 - -[ocr.crop] -left = 0.05 -top = 0.35 -right = 0.95 -bottom = 0.95 - -[ocr] -preprocess = true -scale = 1.5 -use_angle_cls = false -``` - -`self.name` は Discord 自動ミュート判定に使う。 - -`staff.names` は表示対象だが guest 人数には含めない。 - -`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。 - -`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。 - -OCR 設定は速度と精度の調整用。 - -`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。 - -フル画面 OCR に戻す場合。 - -```toml -[ocr.crop] -left = 0 -top = 0 -right = 1 -bottom = 1 -``` - -`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。 - -`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。 - -`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。 - -## guest 名簿 - -`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。 - -雛形は `config/guests.example.txt`。 - -```text -GuestUserA -GuestUserB -GuestUserC -``` - -空行と `#` で始まる行は無視する。 - -## 秘密情報 - -秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。 - -雛形は `config/secrets.example.toml`。 - -```toml -[discord] -webhook_url = "" - -[vrchat] -username = "" -password = "" -``` - -`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。 - -`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。 - -## OSC パラメータ - -Avatar Parameters 側の名前と一致させる。 - -| Parameter | 動作 | -| --- | --- | -| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る | -| `vrc_log` | VRC ログ監視の on/off | -| `ocrEnabled` | VRChat 画面を撮って OCR | -| `translation` | OCR して日本語翻訳 | - -## VRC ログ監視 - -`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。 - -以前のような `output_log = 10` の秒数ポーリング設定は使わない。 - -解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。 - -インスタンスが変わったら在室状態はリセットする。 - -出力例。 - -```text -ワールド入室: Midnight Rooftop -現在Guest: 0/3 -[01:20] [join] GuestUserA 1/3 -[01:21] [join] GuestUserB 2/3 -[01:25] [leave] GuestUserA 1/3 -[01:26] [join] [staff] StaffUserA 1/3 -``` - -notice 条件に入った場合。 - -```text -現在Guest: 2/3 -未入室Guest: -- GuestUserC -``` - -## Discord 自動ミュート - -常時 self monitor が VRChat ログの新規追記だけを見る。 - -新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。 - -過去ログ全体の最後が self leave / join だっただけでは反応しない。 - -`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。 - -## OCR / 翻訳 - -`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。 - -`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。 - -OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。 - -## runtime - -新規の実行ログは基本的に以下へまとめる。 - -```text -runtime/runtime.log -runtime/latest_ocr.png -runtime/latest_screenshot.png -``` - -`runtime/` は Git に入れない。 - -OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。 - -画像は最新ファイルに上書きする。日時別ファイルを増やさない。 - -## ユーザー名照合 - -`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。 - -照合順。 - -1. 完全一致 -2. 正規化一致 -3. 高信頼 fuzzy -4. guest のみ低信頼 fuzzy -5. pending / ambiguous / unmatched - -完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。 - -## Git に入れないもの - -以下はローカル専用。 - -- `config/config.toml` -- `config/secrets.toml` -- `config/guests.txt` -- `runtime/` -- `__pycache__/` -- `*.pyc` -- `.vrchat_cookie.txt` - -秘密情報を含む可能性があるものはコミットしない。 diff --git a/VRWT_Tool/VRC_OSC/requirements.txt b/VRWT_Tool/VRC_OSC/requirements.txt deleted file mode 100644 index ccef526..0000000 --- a/VRWT_Tool/VRC_OSC/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -deep-translator -paddleocr -pillow -pyautogui -pygetwindow -pykakasi -pyperclip -python-osc -rapidfuzz -vrchatapi diff --git a/VRWT_Tool/VRC_OSC/src/app.py b/VRWT_Tool/VRC_OSC/src/app.py deleted file mode 100644 index 6b25eba..0000000 --- a/VRWT_Tool/VRC_OSC/src/app.py +++ /dev/null @@ -1,114 +0,0 @@ -import os -from pathlib import Path -import sys - -os.environ["FLAGS_enable_pir_api"] = "0" -os.environ["FLAGS_use_onednn"] = "0" -os.environ["FLAGS_use_mkldnn"] = "0" -os.environ["FLAGS_use_onednn_bfloat16"] = "0" -os.environ["ONEDNN_VERBOSE"] = "0" - -SRC_DIR = Path(__file__).resolve().parents[1] -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) - -from osc.gateway import VrcOscGateway -from discord_control.actions import setDiscordMute -from ocr.ocr_actions import runOcrFromScreen -from translate.translate_actions import saveTranslationText -from translate.translate_actions import translateTextToJapanese -from vrc_log.log_actions import collectVrchatLog -from vrc_log.log_actions import startSelfMonitor -from vrc_log.log_actions import startVrcLogMonitor -from vrc_log.log_actions import stopSelfMonitor -from vrc_log.log_actions import stopVrcLogMonitor - -PARAM_DISCORD_MUTE = "DiscordSend" -PARAM_LOG_GREP = "vrc_log" -PARAM_OCR = "ocrEnabled" -PARAM_TRANSLATE_OCR = "translation" - - -def create_gateway(): - gateway = VrcOscGateway(host="127.0.0.1", port=9001) - - def on_discord_mute(address, *args): - if not args: - gateway.log("ERROR", "DiscordSend args empty") - return - - if gateway.is_rising_edge(address, args[0]): - gateway.log("INFO", f"received {address} args={args}") - setDiscordMute() - - vrc_log_enabled = False - def on_log_grep(address, *args): - nonlocal vrc_log_enabled - - if not args: - gateway.log("ERROR", "vrc_log args empty") - return - - if gateway.is_rising_edge(address, args[0]): - vrc_log_enabled = not vrc_log_enabled - gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}") - - if vrc_log_enabled: - collectVrchatLog(notify_changed=True) - startVrcLogMonitor() - else: - stopVrcLogMonitor() - - def on_ocr(address, *args): - if not args: - gateway.log("ERROR", "ocrEnabled args empty") - return - - if gateway.is_rising_edge(address, args[0]): - gateway.log("INFO", f"received {address} args={args}") - runOcrFromScreen() - - def on_translate_ocr(address, *args): - if not args: - gateway.log("ERROR", "translateOcr args empty") - return - - if gateway.is_rising_edge(address, args[0]): - gateway.log("INFO", f"received {address} args={args}") - - result = runOcrFromScreen() - if not result: - gateway.log("ERROR", "OCR result is None") - return - - text = result.get("text", "") - if not text.strip(): - gateway.log("INFO", "OCR text is empty. translation skipped") - return - - translated = translateTextToJapanese(text) - if not translated.strip(): - gateway.log("INFO", "translation result is empty") - return - - image_path = result.get("image_path") - stem = image_path.stem if image_path else "translation" - saved = saveTranslationText(stem, translated) - gateway.log("INFO", f"translation saved={saved}") - - gateway.map(PARAM_DISCORD_MUTE, on_discord_mute) - gateway.map(PARAM_LOG_GREP, on_log_grep) - gateway.map(PARAM_OCR, on_ocr) - gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr) - gateway.set_default_debug(enabled=False) - return gateway - - -def main(): - gateway = create_gateway() - startSelfMonitor() - gateway.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py deleted file mode 100644 index bc7e369..0000000 --- a/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path -import sys - -SRC_DIR = Path(__file__).resolve().parents[1] - -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) - -from app import main - - -if __name__ == "__main__": - main() diff --git a/VRWT_Tool/VRC_OSC/src/common/config_loader.py b/VRWT_Tool/VRC_OSC/src/common/config_loader.py deleted file mode 100644 index 364ea60..0000000 --- a/VRWT_Tool/VRC_OSC/src/common/config_loader.py +++ /dev/null @@ -1,130 +0,0 @@ -from pathlib import Path -import tomllib - -from common.project_paths import ROOT_DIR - -CONFIG_DIR = ROOT_DIR / "config" -CONFIG_FILE = CONFIG_DIR / "config.toml" -SECRETS_FILE = CONFIG_DIR / "secrets.toml" -DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml" -DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt" - - -def loadTomlFile(path): - if not path.exists(): - return {} - - return tomllib.loads(path.read_text(encoding="utf-8")) - - -def resolveConfigPath(value, default_path): - if not value: - return default_path - - candidate = Path(value) - if candidate.is_absolute(): - return candidate - - return (ROOT_DIR / candidate).resolve() - - -def loadNameListFile(path): - if not path.exists(): - return [] - - names = [] - - for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines(): - name = line.strip() - if not name or name.startswith("#"): - continue - names.append(name) - - return names - - -def loadSecretConfig(): - return loadTomlFile(SECRETS_FILE) - - -def getSecretValue(section_name, key, default=""): - secrets = loadSecretConfig() - section = secrets.get(section_name, {}) - return str(section.get(key, default)).strip() - - -def loadOcrConfig(): - config = loadTomlFile(CONFIG_FILE) - ocr = config.get("ocr", {}) - crop = ocr.get("crop", {}) - - return { - "crop_left": float(crop.get("left", 0.05)), - "crop_top": float(crop.get("top", 0.35)), - "crop_right": float(crop.get("right", 0.95)), - "crop_bottom": float(crop.get("bottom", 0.95)), - "preprocess": bool(ocr.get("preprocess", True)), - "scale": float(ocr.get("scale", 1.5)), - "use_angle_cls": bool(ocr.get("use_angle_cls", False)), - } - - -def loadVrcLogConfig(): - config = loadTomlFile(CONFIG_FILE) - vrc_log = config.get("vrc_log", {}) - self_section = config.get("self", {}) - event_file_value = ( - vrc_log.get("event_toml") - or vrc_log.get("event_file") - or config.get("event_toml") - or config.get("event_file") - ) - - event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE) - event_config = loadTomlFile(event_file) - - if not event_config: - event_config = vrc_log or config - - guest_section = event_config.get("guest", {}) - guest_names = guest_section.get("names", []) - guest_file_value = ( - guest_section.get("file") - or event_config.get("guest_file") - or config.get("guest_file") - ) - guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE) - - if not guest_names: - guest_names = loadNameListFile(guest_file) - - staff_names = event_config.get("staff", {}).get("names", []) - - if not guest_names and isinstance(event_config.get("guest_names"), list): - guest_names = event_config.get("guest_names", []) - if not staff_names and isinstance(event_config.get("staff_names"), list): - staff_names = event_config.get("staff_names", []) - - notice_section = event_config.get("notice", {}) - missing_count = notice_section.get("missing_count", 0) - - if not missing_count: - missing_count = event_config.get("missing_count", 0) - - self_name = ( - self_section.get("name") - or config.get("self_name") - or vrc_log.get("self_name") - or event_config.get("self", {}).get("name") - or "" - ) - - return { - "config_file": CONFIG_FILE, - "event_file": event_file, - "guest_file": guest_file, - "guest_names": [str(name).strip() for name in guest_names if str(name).strip()], - "staff_names": [str(name).strip() for name in staff_names if str(name).strip()], - "missing_count": int(missing_count), - "self_name": str(self_name).strip(), - } diff --git a/VRWT_Tool/VRC_OSC/src/common/project_paths.py b/VRWT_Tool/VRC_OSC/src/common/project_paths.py deleted file mode 100644 index fa38746..0000000 --- a/VRWT_Tool/VRC_OSC/src/common/project_paths.py +++ /dev/null @@ -1,6 +0,0 @@ -from pathlib import Path - -SRC_DIR = Path(__file__).resolve().parents[1] -ROOT_DIR = SRC_DIR.parent -RUNTIME_DIR = ROOT_DIR / "runtime" -RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log" diff --git a/VRWT_Tool/VRC_OSC/src/common/runtime_log.py b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py deleted file mode 100644 index 862cec0..0000000 --- a/VRWT_Tool/VRC_OSC/src/common/runtime_log.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime - -from common.project_paths import RUNTIME_LOG_FILE - - -def appendRuntimeLog(title, text): - RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - body = str(text).strip() - if not body: - body = "(empty)" - - with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file: - file.write(f"\n[{now}] {title}\n") - file.write(body) - file.write("\n") - - return RUNTIME_LOG_FILE diff --git a/VRWT_Tool/VRC_OSC/src/discord_control/actions.py b/VRWT_Tool/VRC_OSC/src/discord_control/actions.py deleted file mode 100644 index 9d1b68f..0000000 --- a/VRWT_Tool/VRC_OSC/src/discord_control/actions.py +++ /dev/null @@ -1,203 +0,0 @@ -from datetime import datetime -import ctypes -import threading -import time - -import pyautogui -import pygetwindow as gw - -BROWSER_KEYWORDS = [ - "google chrome", - "microsoft edge", - "mozilla firefox", -] - -_discord_muted = True -_discord_mute_lock = threading.Lock() - - -def log(level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - -def findDiscordWindow(): - candidates = [] - - for window in gw.getAllWindows(): - title = (window.title or "").strip() - if not title: - continue - if "discord" not in title.lower(): - continue - candidates.append(window) - - if not candidates: - raise RuntimeError("Discordウィンドウが見つかりません") - - candidates.sort(key=lambda window: -(window.width * window.height)) - return candidates[0] - - -def findBrowserWindow(): - candidates = [] - - for window in gw.getAllWindows(): - title = (window.title or "").strip() - if not title: - continue - lowered = title.lower() - if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): - continue - candidates.append(window) - - if not candidates: - raise RuntimeError("ブラウザウィンドウが見つかりません") - - candidates.sort(key=lambda window: -(window.width * window.height)) - return candidates[0] - - -def findDiscordBrowserWindow(): - candidates = [] - - for window in gw.getAllWindows(): - title = (window.title or "").strip() - if not title: - continue - lowered = title.lower() - if "discord" not in lowered: - continue - if not any(keyword in lowered for keyword in BROWSER_KEYWORDS): - continue - candidates.append(window) - - if not candidates: - return None - - candidates.sort(key=lambda window: -(window.width * window.height)) - return candidates[0] - - -def activateDiscordWindow(): - window = findDiscordWindow() - hwnd = getattr(window, "_hWnd", None) - - try: - if window.isMinimized: - window.restore() - except Exception: - pass - - if hwnd: - try: - ctypes.windll.user32.ShowWindow(hwnd, 9) - except Exception: - pass - - try: - window.activate() - except Exception as e: - log("ERROR", f"Discord activate() failed detail={e}") - - if hwnd: - try: - ctypes.windll.user32.BringWindowToTop(hwnd) - ctypes.windll.user32.SetForegroundWindow(hwnd) - except Exception as e: - log("ERROR", f"Discord SetForegroundWindow failed detail={e}") - - time.sleep(0.15) - - log("INFO", f"Discord window title={window.title}") - return window - - -def findVrchatWindow(): - candidates = [] - - for window in gw.getAllWindows(): - title = (window.title or "").strip() - if not title: - continue - lowered = title.lower() - if lowered == "vrchat": - candidates.append((0, window)) - continue - if lowered.startswith("vrchat "): - candidates.append((1, window)) - continue - if lowered.startswith("vrchat"): - candidates.append((2, window)) - - if not candidates: - raise RuntimeError("VRChatウィンドウが見つかりません") - - candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height))) - return candidates[0][1] - - -def activateWindow(window, label): - if not window: - return - - hwnd = getattr(window, "_hWnd", None) - - try: - if window.isMinimized: - window.restore() - except Exception: - pass - - if hwnd: - try: - ctypes.windll.user32.ShowWindow(hwnd, 9) - except Exception: - pass - - try: - window.activate() - except Exception as e: - log("ERROR", f"{label} activate() failed detail={e}") - - if hwnd: - try: - ctypes.windll.user32.BringWindowToTop(hwnd) - ctypes.windll.user32.SetForegroundWindow(hwnd) - except Exception as e: - log("ERROR", f"{label} SetForegroundWindow failed detail={e}") - - time.sleep(0.15) - try: - pyautogui.click(window.left + window.width // 2, window.top + window.height // 2) - except Exception: - pass - time.sleep(0.1) - log("INFO", f"{label} window title={window.title}") - - -def setDiscordMute(): - log("ACTION", "setDiscordMute called") - try: - activateDiscordWindow() - except Exception as e: - log("ERROR", f"Discord window unavailable detail={e}") - browser = findDiscordBrowserWindow() or findBrowserWindow() - activateWindow(browser, "Browser") - log("INFO", "Discord hotkey Ctrl+Shift+M") - pyautogui.hotkey("ctrl", "shift", "m") - activateWindow(findVrchatWindow(), "VRChat") - - -def setDiscordMuted(muted): - global _discord_muted - - muted = bool(muted) - - with _discord_mute_lock: - if _discord_muted == muted: - log("INFO", f"Discord mute already {'on' if muted else 'off'}") - return - - setDiscordMute() - _discord_muted = muted diff --git a/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py deleted file mode 100644 index 56f2a4a..0000000 --- a/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py +++ /dev/null @@ -1,186 +0,0 @@ -import os -from datetime import datetime - -os.environ["FLAGS_enable_pir_api"] = "0" -os.environ["FLAGS_use_onednn"] = "0" -os.environ["FLAGS_use_mkldnn"] = "0" -os.environ["FLAGS_use_onednn_bfloat16"] = "0" -os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1" -os.environ["ONEDNN_VERBOSE"] = "0" - -import pyperclip -from paddleocr import PaddleOCR -from PIL import Image -from PIL import ImageFilter -from PIL import ImageOps - -from common.config_loader import loadOcrConfig -from common.runtime_log import appendRuntimeLog -from screen.screen_actions import captureOcrRegion - -_ocr_engine = None - - -def log(level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - -def getOcrEngine(): - global _ocr_engine - - if _ocr_engine is None: - log("INFO", "PaddleOCR initializing") - config = loadOcrConfig() - options = { - "lang": "japan", - "use_angle_cls": config["use_angle_cls"], - "show_log": False, - } - - try: - _ocr_engine = PaddleOCR(**options) - except (TypeError, ValueError): - options.pop("show_log", None) - _ocr_engine = PaddleOCR(**options) - - return _ocr_engine - - -def saveOcrText(image_path, text): - return appendRuntimeLog( - "OCR TEXT", - f"image={image_path}\n{text}", - ) - - -def extractTextFromAny(obj): - lines = [] - - def walk(x): - if x is None: - return - - if isinstance(x, dict): - for key in ("rec_texts", "texts"): - value = x.get(key) - if isinstance(value, list): - for item in value: - text = str(item).strip() - if text: - lines.append(text) - - for key in ("text", "rec_text"): - value = x.get(key) - if isinstance(value, str) and value.strip(): - lines.append(value.strip()) - - for value in x.values(): - walk(value) - return - - if isinstance(x, (list, tuple)): - if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1: - candidate = x[1][0] - if isinstance(candidate, str) and candidate.strip(): - lines.append(candidate.strip()) - - for item in x: - walk(item) - - walk(obj) - - unique_lines = [] - seen = set() - for line in lines: - if line not in seen: - unique_lines.append(line) - seen.add(line) - - return "\n".join(unique_lines) - - -def printOcrResult(text): - cleaned = text.strip() - if not cleaned: - log("INFO", "OCR結果は空です") - return - - log("CHECK", "OCR結果 full") - for line in cleaned.splitlines(): - print(line, flush=True) - - -def preprocessOcrImage(image_path): - config = loadOcrConfig() - enabled = config["preprocess"] - if not enabled: - return image_path - - try: - image = Image.open(image_path) - image = image.convert("L") - image = ImageOps.autocontrast(image) - image = image.filter(ImageFilter.SHARPEN) - - scale = max(1.0, min(2.0, config["scale"])) - if scale > 1.0: - width, height = image.size - resampling = getattr( - getattr(Image, "Resampling", Image), - "LANCZOS", - ) - image = image.resize( - (int(width * scale), int(height * scale)), - resampling, - ) - - image.save(image_path) - log("INFO", f"OCR image preprocessed={image_path}") - except Exception as e: - log("ERROR", f"OCR preprocess failed detail={e}") - - return image_path - - -def runPaddleOcr(engine, image_path): - if hasattr(engine, "predict"): - return engine.predict(str(image_path)) - return engine.ocr(str(image_path)) - - -def runOcrFromImage(image_path, show_result=True): - log("ACTION", f"PaddleOCR start image={image_path}") - - try: - image_path = preprocessOcrImage(image_path) - engine = getOcrEngine() - result = runPaddleOcr(engine, image_path) - except Exception as e: - log("ERROR", f"PaddleOCR failed detail={e}") - return None - - text = extractTextFromAny(result) - text_path = saveOcrText(image_path, text) - - try: - pyperclip.copy(text) - log("INFO", "OCR結果をクリップボードへコピーしました") - except Exception as e: - log("ERROR", f"clipboard copy failed detail={e}") - - log("INFO", f"OCR text appended={text_path}") - if show_result: - printOcrResult(text) - - return { - "image_path": image_path, - "text_path": text_path, - "text": text, - } - - -def runOcrFromScreen(): - log("ACTION", "runOcrFromScreen called") - image_path = captureOcrRegion() - return runOcrFromImage(image_path, show_result=True) diff --git a/VRWT_Tool/VRC_OSC/src/osc/gateway.py b/VRWT_Tool/VRC_OSC/src/osc/gateway.py deleted file mode 100644 index 419d88b..0000000 --- a/VRWT_Tool/VRC_OSC/src/osc/gateway.py +++ /dev/null @@ -1,85 +0,0 @@ -from pythonosc.dispatcher import Dispatcher -from pythonosc.osc_server import BlockingOSCUDPServer -from datetime import datetime -import sys - -class VrcOscGateway: - def __init__(self, host="127.0.0.1", port=9001): - self.host = host - self.port = port - self.dispatcher = Dispatcher() - self.last_values = {} - - def log(self, level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - def to_bool(self, value): - if isinstance(value, bool): - return value - - if isinstance(value, int) or isinstance(value, float): - return value != 0 - - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in ("true", "1", "on"): - return True - if lowered in ("false", "0", "off"): - return False - - raise ValueError(f"unsupported OSC value: {value}") - - def is_rising_edge(self, address, value): - current = self.to_bool(value) - previous = self.last_values.get(address, False) - self.last_values[address] = current - return current and not previous - - def is_changed(self, address, value): - current = self.to_bool(value) - previous = self.last_values.get(address, None) - - if previous == current: - return False - - self.last_values[address] = current - return True - - def map(self, parameter_name, handler): - address = f"/avatar/parameters/{parameter_name}" - self.dispatcher.map(address, handler) - self.log("INFO", f"mapped {address}") - - def set_default_debug(self, enabled=False): - if not enabled: - return - - def on_any(address, *args): - if address.startswith("/avatar/parameters/"): - self.log("INFO", f"other address={address} args={args}") - - self.dispatcher.set_default_handler(on_any) - - def serve_forever(self): - self.log("CHECK", "STEP=1 状態確認") - self.log("INFO", f"listen={self.host}:{self.port}") - - self.log("CHECK", "STEP=2 想定状態判定") - self.log("INFO", "VRChat OSC Enabled") - self.log("INFO", "Python は Windows PowerShell 側で実行") - self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること") - - - try: - server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher) - server.serve_forever() - except KeyboardInterrupt: - self.log("INFO", "Ctrl+C detected") - except Exception as e: - self.log("FATAL", f"OSC server error STEP=3 detail={e}") - input("Press Enter to exit...") - sys.exit(1) - - self.log("CHECK", "STEP=4 結果確認") - self.log("INFO", "終了しました") diff --git a/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py deleted file mode 100644 index da0cf0a..0000000 --- a/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py +++ /dev/null @@ -1,167 +0,0 @@ -import pyautogui -import pygetwindow as gw - -from common.config_loader import loadOcrConfig -from common.project_paths import RUNTIME_DIR - -SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png" -OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png" - -EXCLUDED_WINDOW_KEYWORDS = [ - "Google Chrome", - "Microsoft Edge", - "Mozilla Firefox", - "Visual Studio Code", - "PowerShell", - "Windows Terminal", - "ChatGPT", -] - - -def log(level, message): - from datetime import datetime - - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - -def findVrchatWindow(): - windows = gw.getAllWindows() - candidates = [] - - for window in windows: - title = window.title or "" - normalized = title.strip().lower() - if not normalized: - continue - if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS): - continue - - if normalized == "vrchat": - candidates.append((0, window)) - continue - if normalized.startswith("vrchat "): - candidates.append((1, window)) - continue - if normalized.startswith("vrchat"): - candidates.append((2, window)) - - if not candidates: - visible_titles = [] - for window in windows: - title = (window.title or "").strip() - if title: - visible_titles.append(title) - - log("ERROR", "VRChatウィンドウが見つかりません") - log("CHECK", "現在検出できるウィンドウ") - for title in visible_titles[:30]: - print(title, flush=True) - - raise RuntimeError("VRChatウィンドウが見つかりません") - - candidates = sorted( - candidates, - key=lambda item: (item[0], -(item[1].width * item[1].height)), - ) - window = candidates[0][1] - - if window.width <= 0 or window.height <= 0: - raise RuntimeError("VRChatウィンドウサイズが不正です") - - return window - - -def getVrchatRegion(): - window = findVrchatWindow() - - try: - if window.isMinimized: - window.restore() - except Exception: - pass - - left = int(window.left) - top = int(window.top) - width = int(window.width) - height = int(window.height) - - if width <= 0 or height <= 0: - raise RuntimeError("VRChatウィンドウ範囲が不正です") - - log("INFO", f"VRChat window title={window.title}") - log("INFO", f"VRChat region left={left} top={top} width={width} height={height}") - return left, top, width, height - - -def clampRatio(value): - return max(0.0, min(1.0, value)) - - -def getOcrRegion(): - left, top, width, height = getVrchatRegion() - config = loadOcrConfig() - - # Default to the lower central area. It is much faster than OCRing the - # whole VRChat window and usually contains chat/subtitle text. - crop_left = clampRatio(config["crop_left"]) - crop_top = clampRatio(config["crop_top"]) - crop_right = clampRatio(config["crop_right"]) - crop_bottom = clampRatio(config["crop_bottom"]) - - if crop_right <= crop_left: - crop_left = 0.0 - crop_right = 1.0 - - if crop_bottom <= crop_top: - crop_top = 0.0 - crop_bottom = 1.0 - - region_left = left + int(width * crop_left) - region_top = top + int(height * crop_top) - region_width = max(1, int(width * (crop_right - crop_left))) - region_height = max(1, int(height * (crop_bottom - crop_top))) - - log( - "INFO", - f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}", - ) - return region_left, region_top, region_width, region_height - - -def captureRegionTo(image_path, region): - image_path.parent.mkdir(parents=True, exist_ok=True) - - left, top, width, height = region - - image = pyautogui.screenshot(region=(left, top, width, height)) - image.save(image_path) - - log("ACTION", f"captureVrchatWindow saved={image_path}") - return image_path - - -def captureVrchatWindowTo(image_path): - return captureRegionTo( - image_path, - getVrchatRegion(), - ) - - -def captureVrchatWindow(): - return captureVrchatWindowTo(SCREENSHOT_PATH) - - -def captureOcrRegion(): - return captureRegionTo( - OCR_SCREENSHOT_PATH, - getOcrRegion(), - ) - - -def captureScreen(): - return captureVrchatWindow() - - -def takeScreenshot(): - return captureVrchatWindow() diff --git a/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py deleted file mode 100644 index 03514d2..0000000 --- a/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py +++ /dev/null @@ -1,124 +0,0 @@ -from pythonosc.dispatcher import Dispatcher -from pythonosc.osc_server import BlockingOSCUDPServer -from datetime import datetime -import pyautogui -import time -import sys - -HOST = "127.0.0.1" -PORT = 9001 -PARAMETER = "/avatar/parameters/DiscordSend" - -# Discordの初期状態を「ミュート中」と仮定 -discord_muted = True -button_pressed = False -last_action_time = 0.0 -DEBOUNCE_SECONDS = 0.3 - -def log(level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - -def wait_exit(): - input("Press Enter to exit...") - -def press_discord_mute_hotkey(): - pyautogui.hotkey("ctrl", "shift", "m") - -def handle_discord_send(address, *args): - global discord_muted - global button_pressed - global last_action_time - - if not args: - log("ERROR", "OSC args is empty") - return - - value = args[0] - now_ts = time.time() - - if isinstance(value, bool): - is_pressed = value - elif isinstance(value, int) or isinstance(value, float): - is_pressed = value != 0 - else: - log("ERROR", f"unsupported value type value={value}") - return - - if now_ts - last_action_time < DEBOUNCE_SECONDS: - return - - # 押した瞬間: ミュート解除 - if is_pressed and not button_pressed: - button_pressed = True - - if discord_muted: - log("ACTION", "VRChat button pressed -> Discord unmute hotkey") - press_discord_mute_hotkey() - discord_muted = False - last_action_time = now_ts - else: - log("INFO", "already unmuted") - - return - - # 離した瞬間: ミュート - if not is_pressed and button_pressed: - button_pressed = False - - if not discord_muted: - log("ACTION", "VRChat button released -> Discord mute hotkey") - press_discord_mute_hotkey() - discord_muted = True - last_action_time = now_ts - else: - log("INFO", "already muted") - - return - -def main(): - log("CHECK", "STEP=1 OSC受信状態確認") - log("INFO", f"host={HOST}") - log("INFO", f"port={PORT}") - log("INFO", f"parameter={PARAMETER}") - - log("CHECK", "STEP=2 想定状態判定") - log("INFO", "Windows PowerShellで実行していること") - log("INFO", "VRChat OSC Enabledであること") - log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること") - log("INFO", "Discordの現在状態はミュート中として扱います") - - answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower() - - if answer != "yes": - if answer == "no": - log("FATAL", "ユーザー操作により停止 STEP=2") - wait_exit() - sys.exit(1) - - log("ERROR", "不正入力です STEP=2") - wait_exit() - sys.exit(1) - - log("ACTION", "STEP=3 OSC受信開始") - log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します") - log("INFO", "停止する場合は Ctrl+C") - - dispatcher = Dispatcher() - dispatcher.map(PARAMETER, handle_discord_send) - - try: - server = BlockingOSCUDPServer((HOST, PORT), dispatcher) - server.serve_forever() - except KeyboardInterrupt: - log("INFO", "Ctrl+C detected") - except Exception as e: - log("FATAL", f"OSC server error STEP=3 detail={e}") - wait_exit() - sys.exit(1) - - log("CHECK", "STEP=4 結果確認") - log("INFO", "終了しました") - -if __name__ == "__main__": - main() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py deleted file mode 100644 index c21b324..0000000 --- a/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py +++ /dev/null @@ -1,21 +0,0 @@ -from pythonosc.dispatcher import Dispatcher -from pythonosc.osc_server import BlockingOSCUDPServer -from datetime import datetime - -HOST = "127.0.0.1" -PORT = 9001 - -def on_any(address, *args): - now = datetime.now().strftime("%H:%M:%S") - print(f"[INFO] {now} address={address} args={args}", flush=True) - -dispatcher = Dispatcher() -dispatcher.set_default_handler(on_any) - -print("[CHECK] OSC受信開始") -print(f"[INFO] host={HOST}") -print(f"[INFO] port={PORT}") -print("[ACTION] VRChatでメニューを押してください") - -server = BlockingOSCUDPServer((HOST, PORT), dispatcher) -server.serve_forever() diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py deleted file mode 100644 index 5b67fb4..0000000 --- a/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py +++ /dev/null @@ -1,54 +0,0 @@ -from pythonosc.dispatcher import Dispatcher -from pythonosc.osc_server import BlockingOSCUDPServer -from datetime import datetime - -HOST = "127.0.0.1" -PORT = 9001 - -def log(msg): - print(msg, flush=True) - -def on_test(address, *args): - now = datetime.now().strftime("%H:%M:%S") - - log("") - log(f"[INFO] time={now}") - log(f"[ACTION] OSC parameter received") - log(f"[INFO] address={address}") - log(f"[INFO] args={args}") - -dispatcher = Dispatcher() - -dispatcher.map( - "/avatar/parameters/TestPrint", - on_test -) - -log("[CHECK] STEP=1 OSC待受開始") -log(f"[INFO] host={HOST}") -log(f"[INFO] port={PORT}") - -try: - server = BlockingOSCUDPServer( - (HOST, PORT), - dispatcher - ) - - log("[INFO] OSC server started") - log("[ACTION] VRChatでTestPrintを押してください") - - server.serve_forever() - -except KeyboardInterrupt: - log("") - log("[INFO] Ctrl+C detected") - log("[INFO] shutdown") - -except Exception as e: - log("") - log("[FATAL] OSC server error") - log(f"[ERROR] detail={e}") - - print("") - input("Press Enter to exit...") - raise SystemExit(1) diff --git a/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py b/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py deleted file mode 100644 index 9841885..0000000 --- a/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py +++ /dev/null @@ -1,134 +0,0 @@ -import re -from http.cookiejar import MozillaCookieJar - -import vrchatapi -from vrchatapi.api import authentication_api -from vrchatapi.api import users_api -from vrchatapi.exceptions import UnauthorizedException -from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode -from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode - -from common.config_loader import getSecretValue - -COOKIE_FILE = ".vrchat_cookie.txt" - -USER_AGENT = ( - "VRC_OSC/1.0 " - "(contact: test@gmail.com)" -) - - -def get_required_secret(section, key): - value = getSecretValue(section, key) - if not value: - raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml") - return value - - -def setup_cookie_jar(api_client): - cookie_jar = MozillaCookieJar(COOKIE_FILE) - - if os.path.exists(COOKIE_FILE): - try: - cookie_jar.load( - ignore_discard=True, - ignore_expires=True, - ) - except Exception: - pass - - api_client.rest_client.cookie_jar = cookie_jar - return cookie_jar - - -def save_cookie_jar(cookie_jar): - try: - cookie_jar.save( - ignore_discard=True, - ignore_expires=True, - ) - except Exception: - pass - - -def login(api_client, cookie_jar): - auth_api = authentication_api.AuthenticationApi(api_client) - - try: - current_user = auth_api.get_current_user() - save_cookie_jar(cookie_jar) - return current_user - - except UnauthorizedException as e: - reason = str(e.reason) - - if "Email 2 Factor Authentication" in reason: - code = input("Email 2FA Code: ").strip() - - auth_api.verify2_fa_email_code( - TwoFactorEmailCode(code) - ) - - elif "2 Factor Authentication" in reason: - code = input("TOTP Code: ").strip() - - auth_api.verify2_fa( - TwoFactorAuthCode(code) - ) - - else: - raise - - current_user = auth_api.get_current_user() - - save_cookie_jar(cookie_jar) - - return current_user - - -def extract_user_id(text): - match = re.search( - r"(usr_[0-9a-fA-F\-]+)", - text, - ) - - if not match: - return None - - return match.group(1) - - -def main(): - text = input("参加者名: ").strip() - - user_id = extract_user_id(text) - - if not user_id: - print(text) - return - - configuration = vrchatapi.Configuration( - username=get_required_secret("vrchat", "username"), - password=get_required_secret("vrchat", "password"), - ) - - with vrchatapi.ApiClient(configuration) as api_client: - api_client.user_agent = USER_AGENT - - api_client.default_headers["User-Agent"] = USER_AGENT - - cookie_jar = setup_cookie_jar(api_client) - - login(api_client, cookie_jar) - - users = users_api.UsersApi(api_client) - - user = users.get_user(user_id) - - print(user.display_name) - - save_cookie_jar(cookie_jar) - - -if __name__ == "__main__": - main() diff --git a/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py deleted file mode 100644 index 6df4117..0000000 --- a/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py +++ /dev/null @@ -1,102 +0,0 @@ -from datetime import datetime -import re - -import pyperclip -from deep_translator import GoogleTranslator - -from common.runtime_log import appendRuntimeLog - -IGNORE_EXACT = { - "戻る", - "翻訳", - "OCR", - "VRCLOG", - "Discord_Mute", - "Screenshot", - "OCR_Toggle", - "DCR_Toggle", - "HI", -} - - -def log(level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - -def saveTranslationText(stem, text): - return appendRuntimeLog( - "TRANSLATION TEXT", - f"source={stem}\n{text}", - ) - - -def looksLikeNoise(line): - value = line.strip() - if not value: - return True - if value in IGNORE_EXACT: - return True - if len(value) <= 2: - return True - if "_" in value: - return True - if re.fullmatch(r"[A-Za-z0-9\-_/]+", value): - return True - return False - - -def normalizeSourceText(text): - lines = [] - - for raw_line in text.splitlines(): - line = raw_line.strip() - if looksLikeNoise(line): - continue - lines.append(line) - - unique_lines = [] - seen = set() - for line in lines: - if line not in seen: - unique_lines.append(line) - seen.add(line) - - return "\n".join(unique_lines).strip() - - -def translateTextToJapanese(text): - source = normalizeSourceText(text) - if not source: - log("INFO", "翻訳対象が空です") - return "" - - log("CHECK", "Translation source") - for line in source.splitlines(): - print(line, flush=True) - - try: - translated = GoogleTranslator( - source="auto", - target="ja", - ).translate(source) - except Exception as e: - log("ERROR", f"translation failed detail={e}") - return "" - - translated = translated.strip() - if not translated: - log("INFO", "翻訳結果は空です") - return "" - - try: - pyperclip.copy(translated) - log("INFO", "翻訳結果をクリップボードへコピーしました") - except Exception as e: - log("ERROR", f"clipboard copy failed detail={e}") - - log("CHECK", "Translation result") - for line in translated.splitlines(): - print(line, flush=True) - - return translated diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py deleted file mode 100644 index e533599..0000000 --- a/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py +++ /dev/null @@ -1,563 +0,0 @@ -import os -import re -import subprocess -import tempfile -import threading -import time -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -from common.config_loader import loadVrcLogConfig -from common.config_loader import getSecretValue -from common.runtime_log import appendRuntimeLog -from discord_control.actions import setDiscordMuted - -_monitor_thread = None -_monitor_stop_event = threading.Event() -_last_sent_output = None -_self_monitor_thread = None -_self_monitor_stop_event = threading.Event() -_last_self_state = None -REALTIME_IDLE_SECONDS = 0.2 - -JOIN_PATTERN = re.compile( - r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" -) -LEFT_PATTERN = re.compile( - r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)" -) -WORLD_PATTERN = re.compile( - r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)" -) -ENTERING_ROOM_PATTERN = re.compile( - r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$" -) -WORLD_NAME_PATTERN = re.compile( - r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)" -) -TIME_PATTERN = re.compile( - r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})" -) -RELEVANT_LOG_PATTERN = re.compile( - r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_" -) - - -def log(level, message): - now = datetime.now().strftime("%H:%M:%S") - print(f"[{level}] {now} {message}", flush=True) - - -def getWebhookUrl(): - return getSecretValue("discord", "webhook_url") - - -def sendWebhook(text): - webhook_url = getWebhookUrl().strip() - if not webhook_url: - log("ERROR", "Discord webhook URL is not set config=config/secrets.toml") - return False - - message = str(text).strip() - if not message: - log("INFO", "Webhook送信内容が空です") - return False - - if len(message) > 1800: - message = message[-1800:] - - ps_script = r''' -param( - [string]$WebhookUrl, - [string]$Message -) - -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$Payload = @{ content = $Message } | ConvertTo-Json -Compress -$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload) -Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null -''' - - ps_path = None - - try: - with tempfile.NamedTemporaryFile( - mode="w", - suffix=".ps1", - delete=False, - encoding="utf-8-sig", - ) as tmp: - tmp.write(ps_script) - ps_path = tmp.name - - result = subprocess.run( - [ - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - ps_path, - webhook_url, - message, - ], - capture_output=True, - text=True, - timeout=20, - ) - except Exception as e: - log("ERROR", f"Discord webhook PowerShell failed detail={e}") - return False - finally: - if ps_path: - try: - Path(ps_path).unlink(missing_ok=True) - except Exception: - pass - - if result.returncode != 0: - stderr = (result.stderr or "").strip() - stdout = (result.stdout or "").strip() - log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}") - return False - - log("INFO", "Discord webhook sent via PowerShell") - return True - - -def getVrchatLogDir(): - userprofile = os.environ.get("USERPROFILE") - if not userprofile: - raise RuntimeError("USERPROFILE が取得できません") - return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat" - - -def findLatestVrchatLog(): - log_dir = getVrchatLogDir() - if not log_dir.exists(): - raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}") - - candidates = [] - for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"): - candidates.extend(log_dir.glob(pattern)) - - candidates = [path for path in candidates if path.is_file()] - if not candidates: - raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}") - - candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) - return candidates[0] - - -def readTextSafe(path): - for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): - try: - return path.read_text(encoding=encoding, errors="replace") - except Exception: - continue - - raise RuntimeError(f"ログファイルを読めません: {path}") - - -def parseLineTime(line): - matched = TIME_PATTERN.search(line) - if not matched: - return None - - return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S") - - -def formatClock(dt): - return dt.strftime("%H:%M") - - -def getLogStartTime(now): - noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0) - if now >= noon_today: - return noon_today - return noon_today - timedelta(days=1) - - -def splitWorldLocation(location): - if ":" not in location: - return location, "" - - world_id, instance_id = location.split(":", 1) - return world_id, instance_id - - -def extractWorldNameMap(text): - world_name_map = {} - pending_world_name = "" - - for raw_line in text.splitlines(): - room_match = ENTERING_ROOM_PATTERN.search(raw_line) - if room_match: - pending_world_name = room_match.group(1).strip() - - match = WORLD_NAME_PATTERN.search(raw_line) - if match: - world_id = match.group(1).strip() - world_name = match.group(2).strip() - if world_id and world_name: - world_name_map[world_id] = world_name - continue - - world_match = WORLD_PATTERN.search(raw_line) - if world_match and pending_world_name: - location = world_match.group(1).strip() - world_id, _ = splitWorldLocation(location) - if world_id: - world_name_map[world_id] = pending_world_name - pending_world_name = "" - - return world_name_map - - -def formatWorldLabel(world_id, world_name_map): - world_name = world_name_map.get(world_id, "").strip() - if not world_name: - return "不明" - return world_name - - -def classifyUser(name, guest_names, staff_names): - if name in staff_names: - return "staff" - if name in guest_names: - return "guest" - return "other" - - -def isSelf(name, self_name): - return bool(self_name) and name.strip() == self_name.strip() - - -def buildNoticeLines(guest_names, guest_present_set, missing_count): - total_guest = len(guest_names) - current_guest = len(guest_present_set) - threshold = total_guest - missing_count - - if missing_count <= 0: - return [] - if not (threshold <= current_guest < total_guest): - return [] - - missing_guests = [name for name in guest_names if name not in guest_present_set] - if not missing_guests: - return [] - - lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"] - lines.extend(f"- {name}" for name in missing_guests) - return lines - - -def appendNoticeIfNeeded(output_lines, state): - notice_lines = buildNoticeLines( - state["guest_names"], - state["guest_present_set"], - state["missing_count"], - ) - notice_key = tuple(notice_lines) - - if not notice_lines: - state["last_notice_key"] = None - return - - if state["last_notice_key"] == notice_key: - return - - output_lines.extend(notice_lines) - state["last_notice_key"] = notice_key - - -def parseVrchatEvents(text, config): - start_time = getLogStartTime(datetime.now()) - guest_names = config["guest_names"] - staff_names = set(config["staff_names"]) - self_name = config.get("self_name", "") - world_name_map = extractWorldNameMap(text) - - state = { - "location": "", - "world_id": "", - "instance_id": "", - "guest_names": guest_names, - "guest_name_set": set(guest_names), - "staff_names": staff_names, - "guest_present_set": set(), - "staff_present_set": set(), - "member_present_set": set(), - "missing_count": config["missing_count"], - "last_notice_key": None, - } - output_lines = [] - self_state = None - - for raw_line in text.splitlines(): - line_time = parseLineTime(raw_line) - if not line_time or line_time < start_time: - continue - - world_match = WORLD_PATTERN.search(raw_line) - if world_match: - location = world_match.group(1).strip() - world_id, instance_id = splitWorldLocation(location) - - if location != state["location"]: - state["location"] = location - state["world_id"] = world_id - state["instance_id"] = instance_id - state["guest_present_set"].clear() - state["staff_present_set"].clear() - state["member_present_set"].clear() - state["last_notice_key"] = None - output_lines = [] - output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}") - output_lines.append(f"現在Guest: 0/{len(guest_names)}") - - join_match = JOIN_PATTERN.search(raw_line) - if join_match and state["world_id"]: - name = join_match.group(1).strip() - if isSelf(name, self_name): - self_state = "joined" - state["member_present_set"].add(name) - output_lines.append( - f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - continue - - role = classifyUser(name, state["guest_name_set"], state["staff_names"]) - - if role == "guest": - state["guest_present_set"].add(name) - state["member_present_set"].add(name) - output_lines.append( - f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - appendNoticeIfNeeded(output_lines, state) - elif role == "staff": - state["staff_present_set"].add(name) - output_lines.append( - f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - else: - state["member_present_set"].add(name) - output_lines.append( - f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - continue - - left_match = LEFT_PATTERN.search(raw_line) - if left_match and state["world_id"]: - name = left_match.group(1).strip() - if isSelf(name, self_name): - self_state = "left" - state["member_present_set"].discard(name) - output_lines.append( - f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - continue - - role = classifyUser(name, state["guest_name_set"], state["staff_names"]) - - if role == "guest": - state["guest_present_set"].discard(name) - state["member_present_set"].discard(name) - output_lines.append( - f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - appendNoticeIfNeeded(output_lines, state) - elif role == "staff": - state["staff_present_set"].discard(name) - output_lines.append( - f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - else: - state["member_present_set"].discard(name) - output_lines.append( - f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}' - ) - - return output_lines, self_state - - -def extractLatestSelfLogState(text, self_name): - if not self_name: - return None - - latest_state = None - - for raw_line in text.splitlines(): - join_match = JOIN_PATTERN.search(raw_line) - if join_match and isSelf(join_match.group(1).strip(), self_name): - latest_state = "joined" - continue - - left_match = LEFT_PATTERN.search(raw_line) - if left_match and isSelf(left_match.group(1).strip(), self_name): - latest_state = "left" - - return latest_state - - -def grepVrchatLog(pattern=None): - return collectVrchatLog(pattern=pattern, notify_changed=False) - - -def collectVrchatLog(pattern=None, notify_changed=True): - config = loadVrcLogConfig() - latest_log = findLatestVrchatLog() - log("ACTION", f"parseVrchatLog file={latest_log}") - log("INFO", f'event_file={config["event_file"]}') - - text = readTextSafe(latest_log) - lines, self_state = parseVrchatEvents(text, config) - - output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし" - output_path = appendRuntimeLog("VRC LOG", output_text) - - if notify_changed: - global _last_sent_output - if output_text != _last_sent_output: - try: - ok = sendWebhook(output_text) - if ok: - log("INFO", "Discord webhook sent") - _last_sent_output = output_text - else: - log("ERROR", "Discord webhook send failed") - except Exception as e: - log("ERROR", f"Discord webhook failed detail={e}") - - log("INFO", f"result appended={output_path}") - for line in lines or ["[INFO] 対象イベントなし"]: - print(line, flush=True) - - return output_path - - -def _readNewText(path, offset): - for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"): - try: - with path.open("r", encoding=encoding, errors="replace") as file: - file.seek(offset) - return file.read(), file.tell() - except Exception: - continue - - raise RuntimeError(f"ログファイルを読めません: {path}") - - -def _watchVrchatLogChanges(stop_event, label, on_relevant_change): - log("ACTION", f"{label} realtime watcher started") - - current_log = None - offset = 0 - - while not stop_event.is_set(): - try: - latest_log = findLatestVrchatLog() - latest_size = latest_log.stat().st_size - - if current_log is None: - current_log = latest_log - offset = latest_size - log("INFO", f"{label} watching file={current_log}") - continue - - if latest_log != current_log: - current_log = latest_log - offset = 0 - log("INFO", f"{label} watching new file={current_log}") - - if latest_size < offset: - offset = 0 - - if latest_size > offset: - text, offset = _readNewText(latest_log, offset) - - if RELEVANT_LOG_PATTERN.search(text): - on_relevant_change(text) - except Exception as e: - log("ERROR", f"{label} realtime watcher failed detail={e}") - - stop_event.wait(REALTIME_IDLE_SECONDS) - - log("INFO", f"{label} realtime watcher stopped") - - -def _monitor_loop(): - def on_relevant_change(_text): - collectVrchatLog(notify_changed=True) - - _watchVrchatLogChanges( - _monitor_stop_event, - "VRC log monitor", - on_relevant_change, - ) - - -def _self_monitor_loop(): - def on_relevant_change(text): - try: - config = loadVrcLogConfig() - self_state = extractLatestSelfLogState( - text, - config.get("self_name", ""), - ) - - global _last_self_state - if self_state and self_state != _last_self_state: - if self_state == "joined": - setDiscordMuted(True) - elif self_state == "left": - setDiscordMuted(False) - _last_self_state = self_state - except Exception as e: - log("ERROR", f"VRC self monitor failed detail={e}") - - _watchVrchatLogChanges( - _self_monitor_stop_event, - "VRC self monitor", - on_relevant_change, - ) - - -def startVrcLogMonitor(_interval_seconds=None): - global _monitor_thread - - if _monitor_thread and _monitor_thread.is_alive(): - log("INFO", "VRC log monitor already running") - return - - _monitor_stop_event.clear() - _monitor_thread = threading.Thread( - target=_monitor_loop, - daemon=True, - ) - _monitor_thread.start() - - -def startSelfMonitor(_interval_seconds=None): - global _self_monitor_thread - - if _self_monitor_thread and _self_monitor_thread.is_alive(): - log("INFO", "VRC self monitor already running") - return - - _self_monitor_stop_event.clear() - _self_monitor_thread = threading.Thread( - target=_self_monitor_loop, - daemon=True, - ) - _self_monitor_thread.start() - - -def stopVrcLogMonitor(): - _monitor_stop_event.set() - - -def stopSelfMonitor(): - _self_monitor_stop_event.set() diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py deleted file mode 100644 index efe2a34..0000000 --- a/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py +++ /dev/null @@ -1,623 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -目的: -VRChat表示名とTOML登録名を安全な順番で紐づける - -特徴: -- 完全一致を先に確定して候補から除外 -- 正規化一致を確定して候補から除外 -- 残った候補だけを high confidence fuzzy 判定 -- guest のみ low confidence fuzzy で再選別 -- pending / ambiguous / unmatched 判定 -- 記号除去 -- 全角半角統一 -- 小文字化 -- 日本語 -> ローマ字変換 -- rapidfuzz による類似度 -- テストケース付き - -前提: -pip install rapidfuzz pykakasi - -期待結果: -- 100%一致した名前を fuzzy 候補に残さない -- 似た名前同士の誤リンクを減らす -""" - -import re -import sys -import unicodedata -from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Tuple - -try: - from rapidfuzz import fuzz -except Exception as e: - if __name__ == "__main__": - print(f"[FATAL] rapidfuzz import failed: {e}") - input("Press Enter to exit...") - sys.exit(1) - raise - -try: - from pykakasi import kakasi -except Exception as e: - if __name__ == "__main__": - print(f"[FATAL] pykakasi import failed: {e}") - input("Press Enter to exit...") - sys.exit(1) - raise - - -# ========================= -# 設定 -# ========================= - -AUTO_LINK_SCORE = 95 -LOW_CONFIDENCE_SCORE = 80 -AMBIGUOUS_DIFF = 10 -LOW_CONFIDENCE_MAX_REMAINING = 3 -WEAK_MATCH_SCORE = 50 - -STATUS_MATCHED = "matched" -STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence" -STATUS_PENDING = "pending" -STATUS_AMBIGUOUS = "ambiguous" -STATUS_UNMATCHED = "unmatched" -STATUS_UNKNOWN_MEMBER = "unknown_member" - -ROLE_GUEST = "guest" -ROLE_SELF = "self" -ROLE_STAFF = "staff" - -TEST_GUESTS = [ - "okada", - "tanaka", - "yamada", - "messykenty", -] - -TEST_CURRENT_USERS = [ - "岡田", - "ikada", - "okada_", - "OKADA", - "tanaka!!!", - "田中", - "やまだ", - "messy_kenty", - "messy.kenty", - "MESSYKENTY", - "山田", -] - - -@dataclass(frozen=True) -class TomlMember: - name: str - role: str = ROLE_GUEST - - -@dataclass -class MatchResult: - toml_name: str - role: str - status: str - vrchat_name: Optional[str] = None - score: Optional[float] = None - second_score: Optional[float] = None - reason: str = "" - candidates: List[Tuple[str, float]] = field(default_factory=list) - - -# ========================= -# ローマ字変換 -# ========================= - -kks = kakasi() - - -def jp_to_romaji(text: str) -> str: - try: - converted = kks.convert(text) - - result = [] - for item in converted: - result.append(item["hepburn"]) - - return "".join(result) - - except Exception as e: - print(f"[ERROR] romaji convert failed: {text} : {e}") - return text - - -# ========================= -# 正規化 -# ========================= - -def normalize_name(name: str) -> str: - """ - 正規化内容: - - 全角半角統一 - - 小文字化 - - 日本語→ローマ字 - - 記号除去 - """ - - # Unicode normalize - name = unicodedata.normalize("NFKC", name) - - # 小文字化 - name = name.lower() - - # 日本語→ローマ字 - name = jp_to_romaji(name) - - # 記号除去 - name = re.sub(r"[^a-z0-9]", "", name) - - return name.strip() - - -# ========================= -# 類似候補検索 -# ========================= - -def find_candidates( - target_guest: str, - current_users: List[str], -) -> List[Tuple[str, float]]: - - normalized_target = normalize_name(target_guest) - - normalized_map = {} - - for user in current_users: - normalized_map[user] = normalize_name(user) - - results = [] - - for original_name, normalized_name in normalized_map.items(): - - score = fuzz.ratio( - normalized_target, - normalized_name, - ) - - results.append((original_name, score)) - - results.sort(key=lambda x: x[1], reverse=True) - - return results - - -def _to_toml_member(member) -> TomlMember: - if isinstance(member, TomlMember): - return member - - if isinstance(member, str): - return TomlMember(name=member) - - if isinstance(member, dict): - return TomlMember( - name=str(member.get("name", "")), - role=str(member.get("role", ROLE_GUEST)), - ) - - name = getattr(member, "name", "") - role = getattr(member, "role", ROLE_GUEST) - - return TomlMember( - name=str(name), - role=str(role), - ) - - -def _make_candidates( - toml_name: str, - current_users: Iterable[str], -) -> List[Tuple[str, float]]: - normalized_toml = normalize_name(toml_name) - results = [] - - for user in current_users: - score = fuzz.ratio( - normalized_toml, - normalize_name(user), - ) - results.append((user, score)) - - results.sort(key=lambda x: x[1], reverse=True) - - return results - - -def _classify_remaining( - member: TomlMember, - current_users: Iterable[str], -) -> MatchResult: - candidates = _make_candidates( - member.name, - current_users, - ) - - if not candidates: - return MatchResult( - toml_name=member.name, - role=member.role, - status=STATUS_UNMATCHED, - reason="no_candidate", - ) - - top_name, top_score = candidates[0] - second_score = candidates[1][1] if len(candidates) >= 2 else 0 - diff = top_score - second_score - - if top_score < WEAK_MATCH_SCORE: - status = STATUS_UNMATCHED - reason = "weak_candidate" - elif diff < AMBIGUOUS_DIFF: - status = STATUS_AMBIGUOUS - reason = "close_candidates" - else: - status = STATUS_PENDING - reason = "below_auto_link_threshold" - - return MatchResult( - toml_name=member.name, - role=member.role, - status=status, - vrchat_name=top_name, - score=top_score, - second_score=second_score, - reason=reason, - candidates=candidates, - ) - - -def _best_fuzzy_match( - member: TomlMember, - current_users: Iterable[str], - min_score: float, -) -> Optional[MatchResult]: - candidates = _make_candidates( - member.name, - current_users, - ) - - if not candidates: - return None - - top_name, top_score = candidates[0] - second_score = candidates[1][1] if len(candidates) >= 2 else 0 - - if ( - top_score >= min_score - and top_score - second_score >= AMBIGUOUS_DIFF - ): - return MatchResult( - toml_name=member.name, - role=member.role, - status=STATUS_MATCHED, - vrchat_name=top_name, - score=top_score, - second_score=second_score, - reason="fuzzy", - candidates=candidates, - ) - - return None - - -def _apply_fuzzy_round( - remaining_members: Dict[str, TomlMember], - remaining_users: set, - min_score: float, - status: str, - reason: str, - eligible_roles: Optional[set] = None, -) -> List[MatchResult]: - results = [] - - while True: - possible_matches = [] - - for key, member in remaining_members.items(): - if ( - eligible_roles is not None - and member.role not in eligible_roles - ): - continue - - match = _best_fuzzy_match( - member, - remaining_users, - min_score, - ) - - if match is None: - continue - - possible_matches.append((key, match)) - - if not possible_matches: - break - - possible_matches.sort( - key=lambda x: ( - x[1].score if x[1].score is not None else 0, - (x[1].score or 0) - (x[1].second_score or 0), - ), - reverse=True, - ) - - key, match = possible_matches[0] - match.status = status - match.reason = reason - - results.append(match) - remaining_members.pop(key) - remaining_users.remove(match.vrchat_name) - - return results - - -def match_username_sets( - toml_members, - current_users: List[str], -) -> Tuple[List[MatchResult], List[str]]: - """ - TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。 - - 戻り値: - - TOML側の照合結果 - - TOMLに紐づかなかったVRChat名 - """ - - members = [_to_toml_member(member) for member in toml_members] - remaining_members = { - f"{idx}:{member.name}": member - for idx, member in enumerate(members) - } - remaining_users = set(current_users) - results = [] - - # 1. 完全一致: fuzzy判定せず即確定して候補から除外 - for key, member in list(remaining_members.items()): - if member.name in remaining_users: - results.append( - MatchResult( - toml_name=member.name, - role=member.role, - status=STATUS_MATCHED, - vrchat_name=member.name, - score=100, - second_score=None, - reason="exact", - ) - ) - remaining_members.pop(key) - remaining_users.remove(member.name) - - # 2. 正規化一致: 一意に対応できるものだけを確定して除外 - normalized_members: Dict[str, List[str]] = {} - normalized_users: Dict[str, List[str]] = {} - - for key, member in remaining_members.items(): - normalized_members.setdefault( - normalize_name(member.name), - [], - ).append(key) - - for user in remaining_users: - normalized_users.setdefault( - normalize_name(user), - [], - ).append(user) - - for normalized_name, member_keys in normalized_members.items(): - users = normalized_users.get(normalized_name, []) - - if len(member_keys) != 1 or len(users) != 1: - continue - - key = member_keys[0] - user = users[0] - member = remaining_members[key] - - results.append( - MatchResult( - toml_name=member.name, - role=member.role, - status=STATUS_MATCHED, - vrchat_name=user, - score=100, - second_score=None, - reason="normalized", - ) - ) - remaining_members.pop(key) - remaining_users.remove(user) - - # 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める - results.extend( - _apply_fuzzy_round( - remaining_members, - remaining_users, - AUTO_LINK_SCORE, - STATUS_MATCHED, - "high_confidence_fuzzy", - ) - ) - - # 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ - if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING: - results.extend( - _apply_fuzzy_round( - remaining_members, - remaining_users, - LOW_CONFIDENCE_SCORE, - STATUS_MATCHED_LOW_CONFIDENCE, - "guest_low_confidence_fuzzy", - eligible_roles={ROLE_GUEST}, - ) - ) - - # 5. 残りは無理に確定しない - for member in remaining_members.values(): - results.append( - _classify_remaining( - member, - remaining_users, - ) - ) - - unknown_users = sorted(remaining_users) - - return results, unknown_users - - -# ========================= -# 判定 -# ========================= - -def evaluate_guest_match( - guest_name: str, - current_users: List[str], -): - - print("") - print("=" * 60) - print(f"[CHECK] guest={guest_name}") - - normalized_guest = normalize_name(guest_name) - - print(f"[INFO] normalized_guest={normalized_guest}") - - candidates = find_candidates( - guest_name, - current_users, - ) - - if not candidates: - print("[ERROR] no candidates") - return - - print("") - print("[CHECK] candidate list") - - for idx, (name, score) in enumerate(candidates[:5], start=1): - print(f" {idx}. {name:<20} score={score:.2f}") - - top_name, top_score = candidates[0] - - second_score = 0 - - if len(candidates) >= 2: - second_score = candidates[1][1] - - diff = top_score - second_score - - print("") - print(f"[INFO] top_score={top_score:.2f}") - print(f"[INFO] second_score={second_score:.2f}") - print(f"[INFO] diff={diff:.2f}") - - # 自動確定 - if ( - top_score >= AUTO_LINK_SCORE - and diff >= AMBIGUOUS_DIFF - ): - print("") - print("[INFO] AUTO LINK SAFE") - print(f"[INFO] linked => {top_name}") - return - - # 曖昧 - print("") - print("[WARN] ambiguous match") - print("[WARN] auto link disabled") - - print("") - print("[ACTION] manual review required") - - -# ========================= -# main -# ========================= - -def main(): - - print("[CHECK] normalize preview") - - for user in TEST_CURRENT_USERS: - print( - f"{user:<20}" - f" => " - f"{normalize_name(user)}" - ) - - print("") - print("[CHECK] username sync ordered matching test") - - results, unknown_users = match_username_sets( - TEST_GUESTS, - TEST_CURRENT_USERS, - ) - - for result in results: - if result.vrchat_name is None: - print( - f"{result.toml_name:<20}" - f" => " - f"{result.status:<22}" - f" reason={result.reason}" - ) - continue - - score_text = "" - if result.score is not None: - score_text = f" score={result.score:.2f}" - - second_text = "" - if result.second_score is not None: - second_text = f" second={result.second_score:.2f}" - - print( - f"{result.toml_name:<20}" - f" => " - f"{result.vrchat_name:<20}" - f" {result.status:<22}" - f" reason={result.reason}" - f"{score_text}" - f"{second_text}" - ) - - for user in unknown_users: - print( - f"{user:<20}" - f" => " - f"{STATUS_UNKNOWN_MEMBER}" - ) - - print("") - print("[INFO] completed") - - -if __name__ == "__main__": - - print("[CHECK] start") - - answer = input( - "[ACTION] run fuzzy test? (yes/no): " - ).strip().lower() - - if answer != "yes": - print("[FATAL] cancelled") - input("Press Enter to exit...") - sys.exit(1) - - main() diff --git a/VRWT_Tool/VRC_OSC/assets/icon.ico b/assets/icon.ico similarity index 100% rename from VRWT_Tool/VRC_OSC/assets/icon.ico rename to assets/icon.ico diff --git a/VRWT_Tool/VRC_OSC/assets/icon.png b/assets/icon.png similarity index 100% rename from VRWT_Tool/VRC_OSC/assets/icon.png rename to assets/icon.png diff --git a/cmd/vrc_osc_gui/history_view_windows.go b/cmd/vrc_osc_gui/history_view_windows.go index 301a771..27d11db 100644 --- a/cmd/vrc_osc_gui/history_view_windows.go +++ b/cmd/vrc_osc_gui/history_view_windows.go @@ -3,12 +3,14 @@ package main import ( + "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" + "sync" "syscall" "time" "unsafe" @@ -59,6 +61,43 @@ type historyQuery struct { HasTo bool } +type joinLeaveEventSnapshot struct { + At time.Time + Kind string + Name string + Count int +} + +type historyExportRequest struct { + Day time.Time + Selected map[string]bool + Rows []historyDetailRow + ExportDir string + IncludeTime bool + IncludeWorld bool + IncludeJoinLeave bool + CustomEnabled bool + Custom string + RegexText string + Query historyQuery +} + +type historyExportResult struct { + Path string + Err error + Day time.Time + SelectedCount int +} + +var historyEventsCacheMu sync.Mutex +var historyEventsCache struct { + jsonMod time.Time + jsonSize int64 + logMod time.Time + logSize int64 + items []historyEventRecord +} + func ensureHistoryState(app *guiApp) { if app == nil { return @@ -221,9 +260,37 @@ func historyVisitKey(label string, start, end time.Time, worldID, instanceID str return key } -func historyEventsFromLog() []historyEventRecord { - p := filepath.Join(runtimeDir(), "join_leave.log") - b, err := os.ReadFile(p) +func historyEventsFromSnapshot() []historyEventRecord { + jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json") + logPath := filepath.Join(runtimeDir(), "join_leave.log") + jsonInfo, jsonErr := os.Stat(jsonPath) + logInfo, logErr := os.Stat(logPath) + if jsonErr == nil || logErr == nil { + historyEventsCacheMu.Lock() + if historyEventsCache.items != nil && + ((jsonErr == nil && historyEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && historyEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) && + ((logErr == nil && historyEventsCache.logMod.Equal(logInfo.ModTime()) && historyEventsCache.logSize == logInfo.Size()) || logErr != nil) { + out := append([]historyEventRecord(nil), historyEventsCache.items...) + historyEventsCacheMu.Unlock() + return out + } + historyEventsCacheMu.Unlock() + } + if b, err := os.ReadFile(jsonPath); err == nil && len(b) > 0 { + if events := decodeHistoryEventsSnapshot(b); len(events) > 0 { + if jsonErr == nil { + historyEventsCacheMu.Lock() + historyEventsCache.jsonMod = jsonInfo.ModTime() + historyEventsCache.jsonSize = jsonInfo.Size() + historyEventsCache.logMod = time.Time{} + historyEventsCache.logSize = 0 + historyEventsCache.items = append([]historyEventRecord(nil), events...) + historyEventsCacheMu.Unlock() + } + return events + } + } + b, err := os.ReadFile(logPath) if err != nil || len(b) == 0 { return nil } @@ -251,6 +318,76 @@ func historyEventsFromLog() []historyEventRecord { Raw: line, }) } + if logErr == nil { + historyEventsCacheMu.Lock() + historyEventsCache.jsonMod = time.Time{} + historyEventsCache.jsonSize = 0 + historyEventsCache.logMod = logInfo.ModTime() + historyEventsCache.logSize = logInfo.Size() + historyEventsCache.items = append([]historyEventRecord(nil), out...) + historyEventsCacheMu.Unlock() + } + return out +} + +func decodeHistoryEventsSnapshot(b []byte) []historyEventRecord { + type snapshot struct { + UpdatedAt time.Time `json:"updated_at"` + Events []struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + Name string `json:"name"` + Count int `json:"count"` + } `json:"events"` + } + var snap snapshot + if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 { + out := make([]historyEventRecord, 0, len(snap.Events)) + for _, ev := range snap.Events { + out = append(out, historyEventRecord{ + At: ev.At, + Kind: ev.Kind, + Name: ev.Name, + Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name), + }) + } + return dedupeHistoryEventRecords(out) + } + var flat []struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + Name string `json:"name"` + Count int `json:"count"` + } + if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 { + out := make([]historyEventRecord, 0, len(flat)) + for _, ev := range flat { + out = append(out, historyEventRecord{ + At: ev.At, + Kind: ev.Kind, + Name: ev.Name, + Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name), + }) + } + return dedupeHistoryEventRecords(out) + } + return nil +} + +func dedupeHistoryEventRecords(events []historyEventRecord) []historyEventRecord { + if len(events) <= 1 { + return events + } + seen := make(map[string]struct{}, len(events)) + out := make([]historyEventRecord, 0, len(events)) + for _, ev := range events { + key := ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + strings.TrimSpace(ev.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ev) + } return out } @@ -259,9 +396,13 @@ func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow { if !historyInQueryRange(day, q) { return nil } - dayStart, dayEnd := historyDayBounds(day) - events := historyEventsFromLog() + events := historyEventsFromSnapshot() visits := historyVisitSource() + return historyVisitsForDayFromData(app, day, visits, events, q) +} + +func historyVisitsForDayFromData(app *guiApp, day time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyDetailRow { + dayStart, dayEnd := historyDayBounds(day) rows := make([]historyDetailRow, 0, len(visits)) for _, visit := range visits { visitStart := visit.Start @@ -349,11 +490,45 @@ func historyCalendarCellsForMonth(app *guiApp) []historyCalendarCell { monthStart = time.Now() } monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + events := historyEventsFromSnapshot() + visits := historyVisitSource() + return historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q) +} + +func historyCalendarCellsForMonthFromData(app *guiApp, monthStart time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyCalendarCell { counts := map[string]int{} daysInMonth := monthStart.AddDate(0, 1, -1).Day() - for day := 1; day <= daysInMonth; day++ { - date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) - counts[date.Format("2006-01-02")] = len(historyVisitsForDay(app, date)) + if q.Pattern == nil { + monthEnd := monthStart.AddDate(0, 1, 0) + for _, visit := range visits { + visitStart := visit.Start + if visitStart.IsZero() { + continue + } + visitEnd := visit.End + if visitEnd.IsZero() { + visitEnd = time.Now() + } + if !visitStart.Before(monthEnd) || !visitEnd.After(monthStart) { + continue + } + dayCursor := time.Date(visitStart.Year(), visitStart.Month(), visitStart.Day(), 0, 0, 0, 0, monthStart.Location()) + if dayCursor.Before(monthStart) { + dayCursor = monthStart + } + for dayCursor.Before(monthEnd) { + dayStart, dayEnd := historyDayBounds(dayCursor) + if visitStart.Before(dayEnd) && visitEnd.After(dayStart) { + counts[dayCursor.Format("2006-01-02")]++ + } + dayCursor = dayCursor.AddDate(0, 0, 1) + } + } + } else { + for day := 1; day <= daysInMonth; day++ { + date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) + counts[date.Format("2006-01-02")] = len(historyVisitsForDayFromData(app, date, visits, events, q)) + } } firstWeekday := int(monthStart.Weekday()) out := make([]historyCalendarCell, 0, 42) @@ -388,57 +563,119 @@ func sameDay(a, b time.Time) bool { } func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) { - rows := historyVisitsForDay(app, day) - if len(rows) == 0 { - return "", nil + req := historyExportRequest{Day: day, Query: historyQueryFromApp(app)} + if app != nil { + req.Selected = cloneHistorySelection(selected) + req.Rows = cloneHistoryDetailRows(app.historyDetailRows) + req.ExportDir = normalizeHistoryExportDir(app.historyExportDir) + req.IncludeTime = app.historyExportIncludeTime + req.IncludeWorld = app.historyExportIncludeWorld + req.IncludeJoinLeave = app.historyExportIncludeJoinLeave + req.CustomEnabled = app.historyExportCustomEnabled + req.Custom = strings.TrimSpace(app.historyExportCustom) + req.RegexText = strings.TrimSpace(app.historyRegex) } - if len(selected) > 0 { + return exportHistoryDayFromRequest(req) +} + +func exportHistoryDayFromRequest(req historyExportRequest) (string, error) { + rows := append([]historyDetailRow(nil), req.Rows...) + if len(rows) == 0 { + rows = historyVisitsForDayFromData(nil, req.Day, historyVisitSource(), historyEventsFromSnapshot(), req.Query) + } + if len(req.Selected) > 0 { filtered := rows[:0] for _, row := range rows { - if selected[row.Key] { + if req.Selected[row.Key] { filtered = append(filtered, row) } } rows = append([]historyDetailRow(nil), filtered...) } - if len(rows) == 0 { - return "", nil + customPattern := (*regexp.Regexp)(nil) + if req.CustomEnabled { + if s := strings.TrimSpace(req.Custom); s != "" { + if re, err := regexp.Compile(s); err == nil { + customPattern = re + } + } } var b strings.Builder b.WriteString("Date: ") - b.WriteString(day.Format("2006-01-02")) + b.WriteString(req.Day.Format("2006-01-02")) b.WriteString("\n") - if q := historyQueryFromApp(app); q.Pattern != nil { + if len(req.Selected) > 0 { + b.WriteString("Selection: ") + b.WriteString(fmt.Sprintf("%d rows", len(req.Selected))) + b.WriteString("\n") + } + if req.Query.Pattern != nil { b.WriteString("Filter: ") - b.WriteString(strings.TrimSpace(app.historyRegex)) + if strings.TrimSpace(req.RegexText) != "" { + b.WriteString(strings.TrimSpace(req.RegexText)) + } else { + b.WriteString(req.Query.Pattern.String()) + } + b.WriteString("\n") + } + if req.CustomEnabled && customPattern != nil { + b.WriteString("Custom: ") + b.WriteString(strings.TrimSpace(req.Custom)) b.WriteString("\n") } b.WriteString("\n") + if len(rows) == 0 { + if len(req.Selected) > 0 { + b.WriteString("(no selected visits)\n") + } else { + b.WriteString("(no visits)\n") + } + } for _, row := range rows { - b.WriteString("\nWorld: ") - b.WriteString(strings.TrimSpace(row.WorldLabel)) - b.WriteString("\n") - b.WriteString("Time: ") - b.WriteString(row.Start.Format("15:04")) - b.WriteString(" - ") - b.WriteString(row.End.Format("15:04")) - b.WriteString("\n") + rowLines := append([]string(nil), row.Lines...) + if customPattern != nil { + filtered := rowLines[:0] + for _, line := range rowLines { + if customPattern.MatchString(line) || customPattern.MatchString(row.WorldLabel) || customPattern.MatchString(strings.Join(row.Users, " ")) { + filtered = append(filtered, line) + } + } + rowLines = append([]string(nil), filtered...) + } + if req.IncludeWorld { + b.WriteString("\nWorld: ") + b.WriteString(strings.TrimSpace(row.WorldLabel)) + b.WriteString("\n") + } + if req.IncludeTime { + b.WriteString("Time: ") + b.WriteString(historyTimeRangeLabel(row.Start, row.End, row.Current)) + b.WriteString("\n") + } if len(row.Users) > 0 { b.WriteString("Users: ") b.WriteString(strings.Join(row.Users, ", ")) b.WriteString("\n") } - if len(row.Lines) == 0 { - b.WriteString("(no matches)\n") - continue - } - for _, line := range row.Lines { - b.WriteString(line) - b.WriteString("\n") + if req.IncludeJoinLeave { + if len(rowLines) == 0 { + b.WriteString("(no matches)\n") + } + for _, line := range rowLines { + b.WriteString(line) + b.WriteString("\n") + } } } - path := filepath.Join(runtimeDir(), "visit_"+day.Format("20060102")+".log") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + exportDir := runtimeDir() + if strings.TrimSpace(req.ExportDir) != "" { + exportDir = normalizeHistoryExportDir(req.ExportDir) + if !filepath.IsAbs(exportDir) { + exportDir = filepath.Join(runtimeDir(), exportDir) + } + } + path := filepath.Join(exportDir, "visit_"+req.Day.Format("20060102")+".log") + if err := os.MkdirAll(exportDir, 0o755); err != nil { return "", err } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { @@ -447,6 +684,89 @@ func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (str return path, nil } +func cloneHistorySelection(selected map[string]bool) map[string]bool { + if len(selected) == 0 { + return nil + } + out := make(map[string]bool, len(selected)) + for key, ok := range selected { + if ok { + out[key] = true + } + } + return out +} + +func cloneHistoryDetailRows(rows []historyDetailRow) []historyDetailRow { + if len(rows) == 0 { + return nil + } + out := make([]historyDetailRow, 0, len(rows)) + for _, row := range rows { + cloned := row + if len(row.Users) > 0 { + cloned.Users = append([]string(nil), row.Users...) + } + if len(row.Lines) > 0 { + cloned.Lines = append([]string(nil), row.Lines...) + } + out = append(out, cloned) + } + return out +} + +func refreshHistoryCache(app *guiApp, force bool) bool { + if app == nil { + return false + } + ensureHistoryState(app) + if !force && app.historyCacheLoaded && !app.historyReloadPending { + return false + } + events := historyEventsFromSnapshot() + visits := historyVisitSource() + q := historyQueryFromApp(app) + app.historyRows = historyRows() + if app.historyRows == nil { + app.historyRows = []guiWorldVisitRow{} + } + monthStart := app.historyMonth + if monthStart.IsZero() { + monthStart = time.Now() + } + monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + app.historyCalendarCells = historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q) + if app.historyCalendarCells == nil { + app.historyCalendarCells = []historyCalendarCell{} + } + app.historyDetailRows = historyVisitsForDayFromData(app, app.historySelectedDate, visits, events, q) + if app.historyDetailRows == nil { + app.historyDetailRows = []historyDetailRow{} + } + height := int32(132) + for _, row := range app.historyDetailRows { + height += historyDetailRowHeight(row) + 6 + } + if height < 120 { + height = 120 + } + app.historyDetailContentHeightPx = height + app.historyReloadPending = false + app.historyCacheLoaded = true + app.historyStatus = "reloaded: " + time.Now().Format("15:04:05") + return true +} + +func requestHistoryRefresh(app *guiApp, reason string) { + if app == nil { + return + } + app.historyReloadPending = true + if strings.TrimSpace(reason) != "" { + app.historyStatus = reason + } +} + func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { if app == nil { return 0 @@ -479,14 +799,17 @@ func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { } cellH := int32(56) startX := int32(12) - startY := int32(80) + startY := int32(102) for i, wd := range weekdays { x := startX + int32(i)*cellW drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText) } - cells := historyCalendarCellsForMonth(app) - app.historyCalendarCells = app.historyCalendarCells[:0] + cells := app.historyCalendarCells + if len(cells) == 0 { + drawPaneText(hdc, 24, 118, "履歴カレンダーを読み込み中", app.joinBoldHFont, clrMutedText) + drawPaneText(hdc, 24, 146, "しばらくしても出ない場合は再読み込みを押してください", app.hFont, clrMutedText) + } for i, cell := range cells { if cell.Date.IsZero() { continue @@ -500,7 +823,7 @@ func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { Bottom: startY + int32(row+1)*cellH - 4, } cell.Rect = rect - app.historyCalendarCells = append(app.historyCalendarCells, cell) + app.historyCalendarCells[i].Rect = rect fill := uint32(0x00261a11) border := uint32(clrPaneBorder) textColor := uint32(clrText) @@ -533,6 +856,12 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { if app == nil { return 0 } + start := time.Now() + defer func() { + if d := time.Since(start); d > 80*time.Millisecond { + guiLog(fmt.Sprintf("paintHistoryDetailPane slow=%s rows=%d scroll=%d", d, len(app.historyDetailRows), app.historyScrollPos)) + } + }() ensureHistoryState(app) hdc, done := beginPanePaint(hwnd) if hdc == 0 { @@ -544,15 +873,14 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) fillSolid(hdc, rc, clrPaneBgAlt) - rows := historyVisitsForDay(app, app.historySelectedDate) - app.historyDetailRows = app.historyDetailRows[:0] + rows := app.historyDetailRows title := app.historySelectedDate.Format("2006年01月02日") - drawPaneText(hdc, 18, 18, title, app.joinBoldHFont, clrAccentGreen) + drawPaneText(hdc, 18, 52, title, app.joinBoldHFont, clrAccentGreen) summary := fmt.Sprintf("%d件", len(rows)) - drawPaneText(hdc, 18, 44, summary, app.hFont, clrMutedText) + drawPaneText(hdc, 18, 78, summary, app.hFont, clrMutedText) if strings.TrimSpace(app.historyRegex) != "" { - drawPaneText(hdc, 96, 44, "grep: "+app.historyRegex, app.hFont, clrMutedText) + drawPaneText(hdc, 96, 78, "grep: "+app.historyRegex, app.hFont, clrMutedText) } if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" { rangeLabel := strings.TrimSpace(app.historyFromDate) @@ -565,10 +893,13 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { } else { rangeLabel += "..." } - drawPaneText(hdc, 260, 44, rangeLabel, app.hFont, clrMutedText) + drawPaneText(hdc, 260, 78, rangeLabel, app.hFont, clrMutedText) } - app.historyExportRect = winRect{Left: rc.Right - 132, Top: 14, Right: rc.Right - 16, Bottom: 42} + app.historyReloadRect = winRect{Left: rc.Right - 256, Top: 10, Right: rc.Right - 140, Bottom: 38} + drawSettingsBox(hdc, app.historyReloadRect, 0x0038281a, clrPaneBorder) + drawCenteredPaneText(hdc, app.historyReloadRect, "Reload", app.hFont, clrText) + app.historyExportRect = winRect{Left: rc.Right - 132, Top: 10, Right: rc.Right - 16, Bottom: 38} drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen) drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText) if app.historyStatus != "" { @@ -583,11 +914,11 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { } applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight) - y := int32(82 - app.historyScrollPos) - for _, row := range rows { + y := int32(126 - app.historyScrollPos) + for i, row := range rows { rowHeight := historyDetailRowHeight(row) row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4} - app.historyDetailRows = append(app.historyDetailRows, row) + app.historyDetailRows[i].Rect = row.Rect if row.Rect.Bottom < 72 { y += rowHeight continue @@ -605,8 +936,9 @@ func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { border = clrAccentGreen } drawSettingsBox(hdc, row.Rect, fill, border) - drawPaneText(hdc, row.Rect.Left+12, y+6, row.Start.Format("15:04")+" - "+row.End.Format("15:04"), app.hFont, clrMutedText) - drawPaneText(hdc, row.Rect.Left+122, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText) + drawPaneText(hdc, row.Rect.Left+12, y+6, "入室 "+historyEntryTimeLabel(row.Start, false, row.Current), app.hFont, clrMutedText) + drawPaneText(hdc, row.Rect.Left+146, y+6, "退室 "+historyEntryTimeLabel(row.End, true, row.Current), app.hFont, clrMutedText) + drawPaneText(hdc, row.Rect.Right-110, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText) label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont) if label == "" { label = "(unknown)" @@ -635,16 +967,18 @@ func historyDetailContentHeight(hwnd uintptr, app *guiApp) int32 { if app == nil || getClientRectProc == nil { return 0 } - var rc winRect - getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) - rows := historyVisitsForDay(app, app.historySelectedDate) - height := int32(90) + if app.historyDetailContentHeightPx > 0 { + return app.historyDetailContentHeightPx + } + rows := app.historyDetailRows + height := int32(132) for _, row := range rows { height += historyDetailRowHeight(row) + 6 } if height < 120 { height = 120 } + app.historyDetailContentHeightPx = height return height } @@ -666,7 +1000,55 @@ func historyDetailRowHeight(row historyDetailRow) int32 { return height } -func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText *syscall.LazyProc) bool { +func historyTimeRangeLabel(start, end time.Time, current bool) string { + if start.IsZero() { + return "" + } + if end.IsZero() { + end = time.Now() + } + if end.Before(start) { + end = start + } + startLabel := start.Format("15:04") + endLabel := end.Format("15:04") + if !sameDay(start, end) { + startLabel = start.Format("01/02 15:04") + if !current && isMidnight(end) { + endLabel = "23:59" + } else { + endLabel = end.Format("01/02 15:04") + } + } else if !current && isMidnight(end) { + endLabel = "23:59" + } + if current { + return startLabel + " - now" + } + return startLabel + " - " + endLabel +} + +func isMidnight(t time.Time) bool { + return t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 +} + +func historyEntryTimeLabel(t time.Time, isEnd bool, current bool) string { + if t.IsZero() { + if current { + return "now" + } + return "" + } + if current { + return "now" + } + if isEnd && isMidnight(t) { + return "23:59" + } + return t.Format("15:04") +} + +func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, getWindowText, setWindowText *syscall.LazyProc) bool { if app == nil { return false } @@ -689,18 +1071,16 @@ func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText return true } case app.rightHwnd: - if pointInRect(x, y, app.historyExportRect) { - path, err := exportHistoryDay(app, app.historySelectedDate, app.historySelectedKeys) - if err != nil { - app.historyStatus = "export failed: " + err.Error() - } else if path != "" { - app.historyStatus = "exported: " + filepath.Base(path) - } else { - app.historyStatus = "exported: nothing selected" - } + if pointInRect(x, y, app.historyReloadRect) { + requestHistoryRefresh(app, "reloading...") invalidateHistoryPanes(app) return true } + if pointInRect(x, y, app.historyExportRect) { + guiLog(fmt.Sprintf("history export click day=%s status=%q", app.historySelectedDate.Format("2006-01-02"), app.historyStatus)) + startHistoryExport(app, getWindowText, setWindowText) + return true + } for _, row := range app.historyDetailRows { if row.Key == "" || !pointInRect(x, y, row.Rect) { continue @@ -761,6 +1141,7 @@ func historySelectDate(app *guiApp, day time.Time) { app.historySelectedKeys = map[string]bool{} app.historyStatus = "" app.historyScrollPos = 0 + requestHistoryRefresh(app, "reloading...") invalidateHistoryPanes(app) } diff --git a/cmd/vrc_osc_gui/main.go b/cmd/vrc_osc_gui/main.go index e3eb11e..7025dff 100644 --- a/cmd/vrc_osc_gui/main.go +++ b/cmd/vrc_osc_gui/main.go @@ -3,22 +3,95 @@ package main import ( - "os" "log" + "net" + "os" + "os/exec" "path/filepath" + "strconv" + "strings" "vrc_osc_go/internal/app" + "vrc_osc_go/internal/config" ) func main() { + initialTab := parseInitialTab(os.Args[1:]) if exe, err := os.Executable(); err == nil { - _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)) + _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe="+exe+" dir="+filepath.Dir(exe)+" initial_tab="+initialTab) } else { _ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error()) } - if err := runNativeGUI(); err != nil { + ensureRuntimeStarted() + if err := runNativeGUI(initialTab); err != nil { _ = app.AppendRuntimeLog("GUI", "runNativeGUI failed: "+err.Error()) log.Fatal(err) } _ = app.AppendRuntimeLog("GUI", "runNativeGUI returned cleanly") } + +func parseInitialTab(args []string) string { + for _, arg := range args { + switch { + case arg == "--settings" || arg == "-settings" || arg == "/settings": + return "settings" + case arg == "--translate" || arg == "-translate" || arg == "/translate": + return "join" + case arg == "--log" || arg == "-log" || arg == "/log": + return "join" + case strings.HasPrefix(arg, "--tab="): + if tab := normalizeInitialTab(strings.TrimPrefix(arg, "--tab=")); tab != "" { + return tab + } + case strings.HasPrefix(arg, "/tab="): + if tab := normalizeInitialTab(strings.TrimPrefix(arg, "/tab=")); tab != "" { + return tab + } + } + } + return "join" +} + +func normalizeInitialTab(tab string) string { + switch strings.ToLower(strings.TrimSpace(tab)) { + case "join", "log", "logs": + return "join" + case "translate", "translation": + return "join" + case "settings", "setting", "config": + return "settings" + default: + return "" + } +} + +func ensureRuntimeStarted() { + cfg, err := config.Load("") + if err != nil { + _ = app.AppendRuntimeLog("GUI", "runtime autostart skipped config="+err.Error()) + return + } + addr := net.JoinHostPort(cfg.OSC.Host, strconv.Itoa(cfg.OSC.Port)) + probe, err := net.ListenPacket("udp", addr) + if err != nil { + _ = app.AppendRuntimeLog("GUI", "runtime already listening addr="+addr+" err="+err.Error()) + return + } + _ = probe.Close() + + exe, err := os.Executable() + if err != nil { + _ = app.AppendRuntimeLog("GUI", "runtime autostart skipped exe="+err.Error()) + return + } + runtimeExe := filepath.Join(filepath.Dir(exe), "vrc_osc.exe") + if _, err := os.Stat(runtimeExe); err != nil { + _ = app.AppendRuntimeLog("GUI", "runtime autostart missing exe="+runtimeExe+" err="+err.Error()) + return + } + if err := exec.Command(runtimeExe, "--no-gui").Start(); err != nil { + _ = app.AppendRuntimeLog("GUI", "runtime autostart failed exe="+runtimeExe+" err="+err.Error()) + return + } + _ = app.AppendRuntimeLog("GUI", "runtime autostart launched exe="+runtimeExe) +} diff --git a/cmd/vrc_osc_gui/main_nonwindows.go b/cmd/vrc_osc_gui/main_nonwindows.go new file mode 100644 index 0000000..16d1d1f --- /dev/null +++ b/cmd/vrc_osc_gui/main_nonwindows.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func main() {} diff --git a/cmd/vrc_osc_gui/rsrc_windows_amd64.syso b/cmd/vrc_osc_gui/rsrc_windows_amd64.syso new file mode 100644 index 0000000..7223f32 Binary files /dev/null and b/cmd/vrc_osc_gui/rsrc_windows_amd64.syso differ diff --git a/cmd/vrc_osc_gui/tray_windows.go b/cmd/vrc_osc_gui/tray_windows.go index a55d68d..c4eb434 100644 --- a/cmd/vrc_osc_gui/tray_windows.go +++ b/cmd/vrc_osc_gui/tray_windows.go @@ -13,9 +13,9 @@ import ( ) const ( - wmUser = 0x0400 - wmTray = wmUser + 1 - wmCommand = 0x0111 + wmUser = 0x0400 + wmTray = wmUser + 1 + wmCommand = 0x0111 wmDestroy = 0x0002 wmClose = 0x0010 wmRButtonUp = 0x0205 @@ -37,7 +37,7 @@ const ( idOpen = 1001 idExit = 1002 maxTip = 128 - maxClassName = 64 + maxClassName = 64 ) type trayApp struct { diff --git a/cmd/vrc_osc_gui/window_windows.go b/cmd/vrc_osc_gui/window_windows.go index fa8b25d..5d5cc50 100644 --- a/cmd/vrc_osc_gui/window_windows.go +++ b/cmd/vrc_osc_gui/window_windows.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "sync" "syscall" "time" "unsafe" @@ -22,84 +23,102 @@ import ( ) const ( - wsOverlappedWindow = 0x00CF0000 - wsVisible = 0x10000000 - wsChild = 0x40000000 - wsPopup = 0x80000000 - wsClipChildren = 0x02000000 - wsClipSiblings = 0x04000000 - wsBorder = 0x00800000 - wsVScroll = 0x00200000 - esMultiline = 0x0004 - esReadonly = 0x0800 - esAutovscroll = 0x0040 - esAutohscroll = 0x0080 - wmCreateGUI = 0x0001 - wmDestroyGUI = 0x0002 - wmPaint = 0x000F - wmEraseBkgnd = 0x0014 - wmSize = 0x0005 - wmVScroll = 0x0115 - wmMouseWheel = 0x020A - wmCloseGUI = 0x0010 - wmLButtonDown = 0x0201 - wmLButtonUpGUI = 0x0202 - wmNCLButtonDown = 0x00A1 - wmTimerGUI = 0x0113 - wmCommandGUI = 0x0111 - wmSetFont = 0x0030 - htCaption = 2 - tbButtonStructSize = 0x041E - tbAddButtons = 0x0414 - tbAddStringW = 0x044D - tbAutoSize = 0x0421 - tbSetButtonSize = 0x041F - tbSetExtendedStyle = 0x0454 - tbSetMaxTextRows = 0x0428 - tbstyleFlat = 0x0800 - tbstyleList = 0x1000 - tbstyleExMixedBtns = 0x00000008 - btNsButton = 0x0000 - btNsAutoSize = 0x0010 - btNsNoPrefix = 0x0020 - btNsShowText = 0x0040 - tbstateEnabled = 0x04 - ccsTop = 0x00000001 - ccsNoResize = 0x00000004 - ccsNoParentAlign = 0x00000008 - ccsNoDivider = 0x00000040 - iccBarClasses = 0x00000004 - toolbarClass = "ToolbarWindow32" - paneClass = "VRC_OSC_PANE" - idStatus = 2001 - idLog = 2002 - timerRefresh = 1 - headerHeight = 56 - navBarHeight = 58 - contentTop = headerHeight + navBarHeight - bottomBarHeight = 48 - windowWidth = 760 - leftPaneWidth = 445 - leftPaneHeight = 560 - rightPaneWidth = windowWidth - leftPaneWidth - settingsPaneWidth = rightPaneWidth - settingsPaneHeight = leftPaneHeight - clrMainBg = 0x00311c0b - clrHeaderBg = 0x004d2c08 - clrNavBg = 0x0024150b - clrNavActiveBg = 0x00311f12 - clrPaneBg = 0x00372513 - clrPaneBgAlt = 0x00332110 - clrPaneBorder = 0x005b3500 - clrText = 0x00e8edf8 - clrMutedText = 0x007d8aa0 - clrAccentGreen = 0x0067e244 - clrAccentRed = 0x006165ff - clrAccentBlue = 0x00ff9c1f - clrAvatarBg = 0x005a3900 - guiLogTailBytes = 2 * 1024 * 1024 + wsOverlappedWindow = 0x00CF0000 + wsVisible = 0x10000000 + wsChild = 0x40000000 + wsPopup = 0x80000000 + wsClipChildren = 0x02000000 + wsClipSiblings = 0x04000000 + wsBorder = 0x00800000 + wsVScroll = 0x00200000 + esMultiline = 0x0004 + esReadonly = 0x0800 + esAutovscroll = 0x0040 + esAutohscroll = 0x0080 + wsTabstop = 0x00010000 + wmCreateGUI = 0x0001 + wmDestroyGUI = 0x0002 + wmPaint = 0x000F + wmEraseBkgnd = 0x0014 + wmSize = 0x0005 + wmVScroll = 0x0115 + wmMouseWheel = 0x020A + wmCloseGUI = 0x0010 + wmLButtonDown = 0x0201 + wmLButtonUpGUI = 0x0202 + wmChar = 0x0102 + wmNCLButtonDown = 0x00A1 + wmTimerGUI = 0x0113 + wmCommandGUI = 0x0111 + wmSetFont = 0x0030 + wmHistoryExportDone = 0x0401 + enChange = 0x0300 + enKillFocus = 0x0200 + bnClicked = 0x0000 + htCaption = 2 + tbButtonStructSize = 0x041E + tbAddButtons = 0x0414 + tbAddStringW = 0x044D + tbAutoSize = 0x0421 + tbSetButtonSize = 0x041F + tbSetExtendedStyle = 0x0454 + tbSetMaxTextRows = 0x0428 + tbstyleFlat = 0x0800 + tbstyleList = 0x1000 + tbstyleExMixedBtns = 0x00000008 + btNsButton = 0x0000 + btNsAutoSize = 0x0010 + btNsNoPrefix = 0x0020 + btNsShowText = 0x0040 + tbstateEnabled = 0x04 + ccsTop = 0x00000001 + ccsNoResize = 0x00000004 + ccsNoParentAlign = 0x00000008 + ccsNoDivider = 0x00000040 + iccBarClasses = 0x00000004 + toolbarClass = "ToolbarWindow32" + editClass = "EDIT" + paneClass = "VRC_OSC_PANE" + idStatus = 2001 + idLog = 2002 + idHistoryExportDir = 3010 + idHistoryExportCustom = 3011 + idHistoryExportBrowse = 3012 + idHistoryExportSave = 3013 + bifReturnOnlyFsDirs = 0x0001 + bifEditBox = 0x0010 + bifNewDialogStyle = 0x0040 + bffmInitialized = 1 + bffmSetSelectionW = 0x0467 + timerRefresh = 1 + headerHeight = 56 + navBarHeight = 58 + contentTop = headerHeight + navBarHeight + bottomBarHeight = 48 + windowWidth = 760 + leftPaneWidth = 445 + leftPaneHeight = 560 + rightPaneWidth = windowWidth - leftPaneWidth + settingsPaneWidth = rightPaneWidth + settingsPaneHeight = leftPaneHeight + clrMainBg = 0x00311c0b + clrHeaderBg = 0x004d2c08 + clrNavBg = 0x0024150b + clrNavActiveBg = 0x00311f12 + clrPaneBg = 0x00372513 + clrPaneBgAlt = 0x00332110 + clrPaneBorder = 0x005b3500 + clrText = 0x00e8edf8 + clrMutedText = 0x007d8aa0 + clrAccentGreen = 0x0067e244 + clrAccentRed = 0x006165ff + clrAccentBlue = 0x00ff9c1f + clrAvatarBg = 0x005a3900 + guiLogTailBytes = 2 * 1024 * 1024 ) +const translateTabEnabled = false + 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})\]`) var worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`) @@ -131,6 +150,8 @@ var smCXVirtualScreen int32 = 78 var smCYVirtualScreen int32 = 79 var setWindowPosProc *syscall.LazyProc var sendMessageProc *syscall.LazyProc +var postMessageProc *syscall.LazyProc +var shellExecuteProc *syscall.LazyProc var createFontProc *syscall.LazyProc var deleteObjectProc *syscall.LazyProc var getStockObjectProc *syscall.LazyProc @@ -163,59 +184,101 @@ var navBgBrush uintptr var navActiveBrush uintptr var paneAltBrush uintptr var defaultGUIFont uintptr +var vrchatRunningMu sync.Mutex +var vrchatRunningLastCheck time.Time +var vrchatRunningLastValue bool +var guestSnapshotCacheMu sync.Mutex +var guestSnapshotCache struct { + modTime time.Time + size int64 + items []appPkg.GuestStatus +} +var worldHistoryCacheMu sync.Mutex +var worldHistoryCache struct { + modTime time.Time + size int64 + items []guiWorldVisitRecord +} +var joinLeaveEventsCacheMu sync.Mutex +var joinLeaveEventsCache struct { + jsonMod time.Time + jsonSize int64 + logMod time.Time + logSize int64 + items []joinLeaveEventSnapshot +} type guiApp struct { - hwnd uintptr - navBarHwnd uintptr - leftHwnd uintptr - rightHwnd uintptr - settingsPaneHwnd uintptr - tabJoin uintptr - tabTranslate uintptr - tabSettings uintptr - btnTopMost uintptr - btnAutoUnmute uintptr - btnFontDown uintptr - btnFontUp uintptr - activeTab string - topMost bool - autoUnmuteOnLeave bool - lastTopMost bool - fontSize int - hFont uintptr - joinFontSize int - joinHFont uintptr - joinBoldFontSize int - joinBoldHFont uintptr - joinHeadlineSize int - joinHeadlineHFont uintptr - leftPaneText string - rightPaneText string - refreshIntervalValue int - refreshIntervalUnit string - historyRegex string - historyFromDate string - historyToDate string - historyMonth time.Time - historySelectedDate time.Time - historySelectedKeys map[string]bool - historyStatus string - historyCalendarCells []historyCalendarCell - historyDetailRows []historyDetailRow - historyPrevMonthRect winRect - historyNextMonthRect winRect - historyExportRect winRect - currentUsers []userState - currentWorld string - currentUserCount int - historyRows []guiWorldVisitRow - memberScrollPos int - eventScrollPos int - historyScrollPos int - lastSettingsAction time.Time - lastTabAction time.Time - lastInstanceCount int - lastLayoutHeight int + hwnd uintptr + navBarHwnd uintptr + leftHwnd uintptr + rightHwnd uintptr + settingsPaneHwnd uintptr + tabJoin uintptr + tabTranslate uintptr + tabSettings uintptr + btnTopMost uintptr + btnAutoUnmute uintptr + btnFontDown uintptr + btnFontUp uintptr + activeTab string + topMost bool + autoUnmuteOnLeave bool + lastTopMost bool + fontSize int + hFont uintptr + joinFontSize int + joinHFont uintptr + joinBoldFontSize int + joinBoldHFont uintptr + joinHeadlineSize int + joinHeadlineHFont uintptr + leftPaneText string + rightPaneText string + refreshIntervalValue int + refreshIntervalUnit string + historyRegex string + historyFromDate string + historyToDate string + historyMonth time.Time + historySelectedDate time.Time + historySelectedKeys map[string]bool + historyStatus string + historyCalendarCells []historyCalendarCell + historyDetailRows []historyDetailRow + historyPrevMonthRect winRect + historyNextMonthRect winRect + historyReloadRect winRect + historyExportRect winRect + historyReloadPending bool + historyCacheLoaded bool + historyDetailContentHeightPx int32 + settingsExportDirEdit uintptr + settingsExportBrowseBtn uintptr + settingsExportCustomEdit uintptr + settingsExportCustomSaveBtn uintptr + settingsEditSyncing bool + historyExportDir string + historyExportIncludeTime bool + historyExportIncludeWorld bool + historyExportIncludeJoinLeave bool + historyExportCustomEnabled bool + historyExportCustom string + historyExportRunning bool + historyExportResultCh chan historyExportResult + lastSettingsVisible bool + discordMuted bool + currentUsers []userState + currentWorld string + currentUserCount int + historyRows []guiWorldVisitRow + memberScrollPos int + eventScrollPos int + historyScrollPos int + lastSettingsAction time.Time + lastTabAction time.Time + lastInstanceCount int + lastLayoutHeight int } type userState struct { @@ -266,6 +329,7 @@ func runNativeGUI(initialTab string) error { kernel32 := syscall.NewLazyDLL("kernel32.dll") gdi32 := syscall.NewLazyDLL("gdi32.dll") comctl32 := syscall.NewLazyDLL("comctl32.dll") + shell32 := syscall.NewLazyDLL("shell32.dll") registerClass := user32.NewProc("RegisterClassW") createWindowEx := user32.NewProc("CreateWindowExW") defWindowProc := user32.NewProc("DefWindowProcW") @@ -303,17 +367,21 @@ func runNativeGUI(initialTab string) error { translateMessage := user32.NewProc("TranslateMessage") dispatchMessage := user32.NewProc("DispatchMessageW") postQuitMessage := user32.NewProc("PostQuitMessage") + postMessageProc = user32.NewProc("PostMessageW") releaseCapture := user32.NewProc("ReleaseCapture") setTimerProc = user32.NewProc("SetTimer") loadCursor := user32.NewProc("LoadCursorW") setWindowText := user32.NewProc("SetWindowTextW") + getWindowText := user32.NewProc("GetWindowTextW") getWindowRect := user32.NewProc("GetWindowRect") isWindowVisible := user32.NewProc("IsWindowVisible") isIconic := user32.NewProc("IsIconic") getModuleHandle := kernel32.NewProc("GetModuleHandleW") loadImage := user32.NewProc("LoadImageW") + setActiveWindow := user32.NewProc("SetActiveWindow") setForegroundWindow := user32.NewProc("SetForegroundWindow") bringWindowToTop := user32.NewProc("BringWindowToTop") + shellExecuteProc = shell32.NewProc("ShellExecuteW") type wndClass struct { style uint32 @@ -365,6 +433,9 @@ func runNativeGUI(initialTab string) error { if app.activeTab == "" { app.activeTab = "join" } + if app.activeTab == "translate" && !translateTabEnabled { + app.activeTab = "join" + } guiCfg := loadGUISettings() app.topMost = guiCfg.TopMost app.autoUnmuteOnLeave = guiCfg.AutoUnmuteOnSelfLeave @@ -384,7 +455,18 @@ func runNativeGUI(initialTab string) error { app.historyRegex = strings.TrimSpace(guiCfg.HistoryRegex) app.historyFromDate = strings.TrimSpace(guiCfg.HistoryFromDate) app.historyToDate = strings.TrimSpace(guiCfg.HistoryToDate) + app.historyExportDir = strings.TrimSpace(guiCfg.HistoryExportDir) + if app.historyExportDir == "" { + app.historyExportDir = filepath.Join(runtimeDir(), "exports") + } + app.historyExportIncludeTime = guiCfg.HistoryExportIncludeTime + app.historyExportIncludeWorld = guiCfg.HistoryExportIncludeWorld + app.historyExportIncludeJoinLeave = guiCfg.HistoryExportIncludeJoinLeave + app.historyExportCustomEnabled = guiCfg.HistoryExportCustomEnabled + app.historyExportCustom = strings.TrimSpace(guiCfg.HistoryExportCustom) + app.historyReloadPending = true app.hFont = createAppFont(app.fontSize) + refreshHistoryCache(&app, true) paneWndProc := syscall.NewCallback(func(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr { defer func() { if r := recover(); r != nil { @@ -418,15 +500,35 @@ func runNativeGUI(initialTab string) error { if handlePaneMouseWheel(hwnd, &app, int32(int16(wParam>>16))) { return 0 } + case wmCommandGUI: + if hwnd == app.settingsPaneHwnd { + cmd := uint16(wParam & 0xffff) + notify := uint16((wParam >> 16) & 0xffff) + if cmd == idHistoryExportBrowse && notify == bnClicked { + if handleHistoryExportBrowseCommand(&app, setWindowText) { + return 0 + } + } + if cmd == idHistoryExportSave && notify == bnClicked { + if handleHistoryExportSaveCommand(&app, getWindowText, setWindowText) { + return 0 + } + } + if notify == enChange || notify == enKillFocus { + if handleHistoryExportEditCommand(&app, cmd, notify, getWindowText, setWindowText) { + return 0 + } + } + } case wmLButtonUpGUI: if (hwnd == app.settingsPaneHwnd || hwnd == app.leftHwnd) && app.activeTab == "settings" { - if handleSettingsPaneClick(&app, int32(lParam&0xffff), int32((lParam>>16)&0xffff), setWindowText) { + if handleSettingsPaneClick(&app, int32(lParam&0xffff), int32((lParam>>16)&0xffff), getWindowText, setWindowText) { return 0 } } if app.activeTab == "history" { if hwnd == app.leftHwnd || hwnd == app.rightHwnd { - if handleHistoryPaneClick(&app, hwnd, int32(lParam&0xffff), int32((lParam>>16)&0xffff), setWindowText) { + if handleHistoryPaneClick(&app, hwnd, int32(lParam&0xffff), int32((lParam>>16)&0xffff), getWindowText, setWindowText) { return 0 } } @@ -466,12 +568,15 @@ func runNativeGUI(initialTab string) error { case wmCreateGUI: app.hwnd = hwnd buttonClass, _ := syscall.UTF16PtrFromString("BUTTON") + editClassTitle, _ := syscall.UTF16PtrFromString(editClass) leftTitle, _ := syscall.UTF16PtrFromString("") rightTitle, _ := syscall.UTF16PtrFromString("") topMostTitle, _ := syscall.UTF16PtrFromString("Top most") autoUnmuteTitle, _ := syscall.UTF16PtrFromString("Leave unmute") fontDownTitle, _ := syscall.UTF16PtrFromString("A-") fontUpTitle, _ := syscall.UTF16PtrFromString("A+") + browseTitle, _ := syscall.UTF16PtrFromString("参照") + saveTitle, _ := syscall.UTF16PtrFromString("保存") toolbarTitle, _ := syscall.UTF16PtrFromString("") if initCommonControlsExProc != nil { icc := initCommonControlsEx{ @@ -501,7 +606,7 @@ func runNativeGUI(initialTab string) error { baseStringIndex, _, _ := sendMessageProc.Call(app.navBarHwnd, tbAddStringW, 0, uintptr(unsafe.Pointer(&toolbarStrings[0]))) buttons := []toolbarButton{ {iBitmap: -2, idCommand: 3001, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex}, - {iBitmap: -2, idCommand: 3002, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 1}, + {iBitmap: -2, idCommand: 3002, fsState: 0, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 1}, {iBitmap: -2, idCommand: 3003, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 2}, {iBitmap: -2, idCommand: 3004, fsState: tbstateEnabled, fsStyle: btNsButton | btNsAutoSize | btNsNoPrefix | btNsShowText, iString: baseStringIndex + 3}, } @@ -514,34 +619,99 @@ func runNativeGUI(initialTab string) error { app.leftHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(leftTitle)), paneStyle, 0, contentTop, leftPaneWidth, leftPaneHeight, hwnd, idStatus, hInstance, 0) app.rightHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(rightTitle)), paneStyle, leftPaneWidth, contentTop, rightPaneWidth, leftPaneHeight, hwnd, idLog, hInstance, 0) settingsPaneTitle, _ := syscall.UTF16PtrFromString("") - app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(settingsPaneTitle)), paneStyle, leftPaneWidth, contentTop, settingsPaneWidth, settingsPaneHeight, hwnd, idLog+1, hInstance, 0) + app.settingsPaneHwnd, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(paneClassName)), uintptr(unsafe.Pointer(settingsPaneTitle)), paneStyle, 0, contentTop, windowWidth, settingsPaneHeight, hwnd, idLog+1, hInstance, 0) app.btnTopMost, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(topMostTitle)), buttonStyle, leftPaneWidth+32, contentTop+86, 140, 30, hwnd, 3005, hInstance, 0) app.btnAutoUnmute, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(autoUnmuteTitle)), buttonStyle, leftPaneWidth+32, contentTop+132, 180, 30, hwnd, 3008, hInstance, 0) app.btnFontDown, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontDownTitle)), buttonStyle, leftPaneWidth+32, contentTop+178, 52, 30, hwnd, 3006, hInstance, 0) app.btnFontUp, _, _ = createWindowEx.Call(0, uintptr(unsafe.Pointer(buttonClass)), uintptr(unsafe.Pointer(fontUpTitle)), buttonStyle, leftPaneWidth+92, contentTop+178, 52, 30, hwnd, 3007, hInstance, 0) + exportDirTitle, _ := syscall.UTF16PtrFromString("") + app.settingsExportDirEdit, _, _ = createWindowEx.Call( + 0, + uintptr(unsafe.Pointer(editClassTitle)), + uintptr(unsafe.Pointer(exportDirTitle)), + uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll), + 116, 410, 520, 24, + app.settingsPaneHwnd, idHistoryExportDir, hInstance, 0, + ) + browseStyle := uintptr(wsChild | wsTabstop) + app.settingsExportBrowseBtn, _, _ = createWindowEx.Call( + 0, + uintptr(unsafe.Pointer(buttonClass)), + uintptr(unsafe.Pointer(browseTitle)), + browseStyle, + 644, 410, 92, 24, + app.settingsPaneHwnd, idHistoryExportBrowse, hInstance, 0, + ) + exportCustomTitle, _ := syscall.UTF16PtrFromString("") + app.settingsExportCustomEdit, _, _ = createWindowEx.Call( + 0, + uintptr(unsafe.Pointer(editClassTitle)), + uintptr(unsafe.Pointer(exportCustomTitle)), + uintptr(wsChild|wsVisible|wsBorder|wsTabstop|esAutohscroll), + 116, 520, 520, 24, + app.settingsPaneHwnd, idHistoryExportCustom, hInstance, 0, + ) + app.settingsExportCustomSaveBtn, _, _ = createWindowEx.Call( + 0, + uintptr(unsafe.Pointer(buttonClass)), + uintptr(unsafe.Pointer(saveTitle)), + browseStyle, + 644, 520, 92, 24, + app.settingsPaneHwnd, idHistoryExportSave, hInstance, 0, + ) applyRefreshTimer(hwnd, &app) applyFont(&app) + syncHistoryExportEditControls(&app, setWindowText) refreshGUI(setWindowText, &app, true) applyTopMost(hwnd, &app) ensureWindowVisible("startup", hwnd, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetricsProc) + raiseWindowToFront(hwnd, showWindowProc, setWindowPosProc, setActiveWindow, setForegroundWindow, bringWindowToTop) logWindowState("after-ensure", hwnd, getWindowRect, isWindowVisible, isIconic) return 0 case wmCommandGUI: needsFullRefresh := false switch uint16(wParam & 0xffff) { case 3001: + guiLog("click tab command join") app.activeTab = "join" needsFullRefresh = true case 3002: - app.activeTab = "translate" - needsFullRefresh = true + guiLog("click tab command translate") + if translateTabEnabled { + app.activeTab = "translate" + needsFullRefresh = true + } else { + guiLog("translate tab disabled") + return 0 + } case 3003: + guiLog("click tab command settings") app.activeTab = "settings" needsFullRefresh = true case 3004: + guiLog("click tab command history") app.activeTab = "history" - needsFullRefresh = true + app.lastSettingsVisible = false + if !app.historyCacheLoaded { + requestHistoryRefresh(&app, "reloading...") + } + syncPaneVisibility(&app) + refreshCommandUI(setWindowText, &app) + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + if invalidateRectProc != nil { + if app.leftHwnd != 0 { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } + if app.rightHwnd != 0 { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } + if app.hwnd != 0 { + invalidateRectProc.Call(app.hwnd, 0, 1) + } + } + return 0 case 3005: + guiLog("click settings command topmost") if !allowRapidSettingsAction(&app) { return 0 } @@ -549,12 +719,26 @@ func runNativeGUI(initialTab string) error { saveGUISettings(&app) applyTopMost(hwnd, &app) case 3008: + guiLog("click settings command leave_unmute") if !allowRapidSettingsAction(&app) { return 0 } app.autoUnmuteOnLeave = !app.autoUnmuteOnLeave saveGUISettings(&app) + case 3012: + guiLog("click settings command export_browse") + if handleHistoryExportBrowseCommand(&app, setWindowText) { + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + return 0 + } + case 3013: + guiLog("click settings command export_save") + if handleHistoryExportSaveCommand(&app, getWindowText, setWindowText) { + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + return 0 + } case 3006: + guiLog("click settings command font_down") if !allowRapidSettingsAction(&app) { return 0 } @@ -569,6 +753,7 @@ func runNativeGUI(initialTab string) error { saveGUISettings(&app) } case 3007: + guiLog("click settings command font_up") if !allowRapidSettingsAction(&app) { return 0 } @@ -622,10 +807,13 @@ func runNativeGUI(initialTab string) error { case x < 240: nextTab = "history" case x < 360: - nextTab = "translate" + if translateTabEnabled { + nextTab = "translate" + } case x < 480: nextTab = "settings" } + guiLog(fmt.Sprintf("click tab area x=%d y=%d active=%s next=%s", x, y, app.activeTab, nextTab)) if nextTab == app.activeTab { return 0 } @@ -633,6 +821,27 @@ func runNativeGUI(initialTab string) error { return 0 } app.activeTab = nextTab + if nextTab == "history" { + app.lastSettingsVisible = false + if !app.historyCacheLoaded { + requestHistoryRefresh(&app, "reloading...") + } + syncPaneVisibility(&app) + refreshCommandUI(setWindowText, &app) + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + if invalidateRectProc != nil { + if app.leftHwnd != 0 { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } + if app.rightHwnd != 0 { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } + if app.hwnd != 0 { + invalidateRectProc.Call(app.hwnd, 0, 1) + } + } + return 0 + } refreshGUI(setWindowText, &app, false) refreshSettingsControls(showWindowProc, setWindowPosProc, &app) if invalidateRectProc != nil { @@ -641,9 +850,29 @@ func runNativeGUI(initialTab string) error { return 0 } case wmTimerGUI: - refreshGUI(setWindowText, &app, true) + drainHistoryExportResult(&app) + reloadData := app.activeTab == "join" || app.activeTab == "translate" + if app.activeTab == "history" && app.historyReloadPending { + if refreshHistoryCache(&app, true) { + refreshGUI(setWindowText, &app, false) + } + refreshSettingsControls(showWindowProc, setWindowPosProc, &app) + return 0 + } + refreshGUI(setWindowText, &app, reloadData) refreshSettingsControls(showWindowProc, setWindowPosProc, &app) return 0 + case wmHistoryExportDone: + drainHistoryExportResult(&app) + if invalidateRectProc != nil { + if app.rightHwnd != 0 { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } + if app.hwnd != 0 { + invalidateRectProc.Call(app.hwnd, 0, 1) + } + } + return 0 case wmDestroyGUI: guiLog("wmDestroy received") postQuitMessage.Call(0) @@ -745,26 +974,104 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { if app == nil { return } + start := time.Now() + defer func() { + if d := time.Since(start); d > 100*time.Millisecond { + guiLog(fmt.Sprintf("refreshGUI slow=%s tab=%s reload=%t history=%d current=%d", d, app.activeTab, reloadData, len(app.historyRows), app.currentUserCount)) + } + }() current := app.currentUsers instanceCount := app.currentUserCount worldLabel := app.currentWorld + state := readRuntimeSnapshot() + historyDirty := false + historyNeedsRefresh := app.activeTab == "history" && (app.historyReloadPending || !app.historyCacheLoaded) + if historyNeedsRefresh { + historyDirty = refreshHistoryCache(app, false) + } if reloadData { - current, instanceCount = currentUsersFromJoinLeave() - guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount)) - if instanceCount == 0 && app.lastInstanceCount > 0 { - instanceCount = app.lastInstanceCount + if app.activeTab == "translate" { + guiLog("refreshGUI activeTab=translate light refresh") + if instanceCount == 0 { + instanceCount = countPresent(current) + } + if instanceCount == 0 && app.lastInstanceCount > 0 { + instanceCount = app.lastInstanceCount + } + if instanceCount > 0 { + app.lastInstanceCount = instanceCount + } + if world := strings.TrimSpace(stateString(state, "world")); world != "" { + worldLabel = world + } + if strings.TrimSpace(worldLabel) == "" { + worldLabel = "(unknown)" + } + app.currentWorld = worldLabel + } else if app.activeTab == "history" { + if historyDirty { + guiLog(fmt.Sprintf("refreshGUI activeTab=history current=%d reload=%t", instanceCount, app.historyReloadPending)) + } else { + guiLog(fmt.Sprintf("refreshGUI activeTab=history cached current=%d", instanceCount)) + } + if out, ok := currentUsersFromGuestSnapshot(); ok { + current = out + instanceCount = countPresent(out) + app.currentUsers = current + app.currentUserCount = instanceCount + } else if instanceCount == 0 && app.lastInstanceCount > 0 { + instanceCount = app.lastInstanceCount + } + if instanceCount > 0 { + app.lastInstanceCount = instanceCount + } + if world := strings.TrimSpace(stateString(state, "world")); world != "" { + worldLabel = world + } + if strings.TrimSpace(worldLabel) == "" { + worldLabel = "(unknown)" + } + app.currentWorld = worldLabel + } else if app.activeTab == "settings" { + if world := strings.TrimSpace(stateString(state, "world")); world != "" { + worldLabel = world + } + if instanceCount == 0 && len(current) == 0 { + if out, ok := currentUsersFromGuestSnapshot(); ok { + current = out + instanceCount = countPresent(out) + app.currentUsers = current + app.currentUserCount = instanceCount + } + } + if instanceCount == 0 && app.lastInstanceCount > 0 { + instanceCount = app.lastInstanceCount + } + if instanceCount > 0 { + app.lastInstanceCount = instanceCount + } + if strings.TrimSpace(worldLabel) == "" { + worldLabel = "(unknown)" + } + app.currentWorld = worldLabel + } else { + current, instanceCount = currentUsersFromJoinLeave() + guiLog(fmt.Sprintf("refreshGUI activeTab=%s current=%d", app.activeTab, instanceCount)) + if instanceCount == 0 && app.lastInstanceCount > 0 { + instanceCount = app.lastInstanceCount + } + if instanceCount > 0 { + app.lastInstanceCount = instanceCount + } + app.currentUsers = current + app.currentUserCount = instanceCount + app.historyRows = historyRows() + worldLabel = currentWorldLabel() + if strings.TrimSpace(worldLabel) == "" { + worldLabel = "(unknown)" + } + app.currentWorld = worldLabel } - if instanceCount > 0 { - app.lastInstanceCount = instanceCount - } - app.currentUsers = current - app.currentUserCount = instanceCount - app.historyRows = historyRows() - worldLabel = currentWorldLabel() - if strings.TrimSpace(worldLabel) == "" { - worldLabel = "(unknown)" - } - app.currentWorld = worldLabel } else { if instanceCount == 0 { instanceCount = countPresent(current) @@ -774,8 +1081,10 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { } guiLog(fmt.Sprintf("refreshGUI cached activeTab=%s current=%d", app.activeTab, instanceCount)) } + app.discordMuted = stateBool(state, "discord_muted") + syncPaneVisibility(app) if app.leftHwnd != 0 { - if app.activeTab == "join" || app.activeTab == "history" || app.activeTab == "settings" { + if app.activeTab == "join" || app.activeTab == "history" { showWindowProc.Call(app.leftHwnd, swShowControl) switch app.activeTab { case "join": @@ -793,7 +1102,9 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { app.leftPaneText = "" } if invalidateRectProc != nil { - invalidateRectProc.Call(app.leftHwnd, 0, 1) + if app.activeTab != "history" || !reloadData || historyDirty { + invalidateRectProc.Call(app.leftHwnd, 0, 1) + } } } else { showWindowProc.Call(app.leftHwnd, swHideControl) @@ -816,31 +1127,19 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { setWindowText.Call(app.btnAutoUnmute, uintptr(unsafe.Pointer(t))) } if app.activeTab == "settings" { - state := readRuntimeSnapshot() - state["top_most"] = app.topMost - state["font_size"] = app.fontSize - state["auto_unmute_on_self_leave"] = app.autoUnmuteOnLeave - if app.rightHwnd != 0 { - showWindowProc.Call(app.rightHwnd, swHideControl) - if invalidateRectProc != nil { - invalidateRectProc.Call(app.rightHwnd, 0, 1) + if !app.lastSettingsVisible { + resizeGUIForSettings(app) + if setWindowText != nil { + syncHistoryExportEditControls(app, setWindowText) } - } - if app.settingsPaneHwnd != 0 { - showWindowProc.Call(app.settingsPaneHwnd, swHideControl) - } - for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { - if hwnd != 0 { - showWindowProc.Call(hwnd, swHideControl) + if invalidateRectProc != nil && app.settingsPaneHwnd != 0 { + invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1) } } - if invalidateRectProc != nil && app.hwnd != 0 { - invalidateRectProc.Call(app.hwnd, 0, 1) - } return } + app.lastSettingsVisible = false if app.rightHwnd != 0 { - state := readRuntimeSnapshot() state["top_most"] = app.topMost state["font_size"] = app.fontSize state["auto_unmute_on_self_leave"] = app.autoUnmuteOnLeave @@ -848,7 +1147,9 @@ func refreshGUI(setWindowText *syscall.LazyProc, app *guiApp, reloadData bool) { app.rightPaneText = formatRightPane(app.activeTab, state, worldLabel, instanceCount, current, len(app.historyRows)) showWindowProc.Call(app.rightHwnd, swShowControl) if invalidateRectProc != nil { - invalidateRectProc.Call(app.rightHwnd, 0, 1) + if app.activeTab != "history" || !reloadData || historyDirty { + invalidateRectProc.Call(app.rightHwnd, 0, 1) + } } } else { showWindowProc.Call(app.rightHwnd, swHideControl) @@ -897,23 +1198,67 @@ func refreshSettingsControls(showWindowProc, setWindowPosProc *syscall.LazyProc, return } if app.activeTab == "settings" { + if app.lastSettingsVisible { + return + } + app.lastSettingsVisible = true for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { if hwnd != 0 { showWindowProc.Call(hwnd, swHideControl) } } - guiLog("refreshSettingsControls custom") + for _, hwnd := range []uintptr{app.settingsExportDirEdit, app.settingsExportCustomEdit} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swShowControl) + } + } + for _, hwnd := range []uintptr{app.settingsExportBrowseBtn, app.settingsExportCustomSaveBtn} { + if hwnd != 0 { + showWindowProc.Call(hwnd, swShowControl) + } + } return } + if !app.lastSettingsVisible { + return + } + app.lastSettingsVisible = false if app.settingsPaneHwnd != 0 { showWindowProc.Call(app.settingsPaneHwnd, swHideControl) } - for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp} { + for _, hwnd := range []uintptr{app.btnTopMost, app.btnAutoUnmute, app.btnFontDown, app.btnFontUp, app.settingsExportDirEdit, app.settingsExportCustomEdit, app.settingsExportBrowseBtn, app.settingsExportCustomSaveBtn} { if hwnd != 0 { showWindowProc.Call(hwnd, swHideControl) } } - guiLog("refreshSettingsControls hidden") +} + +func syncPaneVisibility(app *guiApp) { + if app == nil || showWindowProc == nil { + return + } + switch app.activeTab { + case "settings": + if app.leftHwnd != 0 { + showWindowProc.Call(app.leftHwnd, swHideControl) + } + if app.rightHwnd != 0 { + showWindowProc.Call(app.rightHwnd, swHideControl) + } + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swShowControl) + } + default: + if app.leftHwnd != 0 { + showWindowProc.Call(app.leftHwnd, swShowControl) + } + if app.rightHwnd != 0 { + showWindowProc.Call(app.rightHwnd, swShowControl) + } + if app.settingsPaneHwnd != 0 { + showWindowProc.Call(app.settingsPaneHwnd, swHideControl) + } + } } func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPosProc, getWindowRect, isWindowVisible, isIconic, getSystemMetrics *syscall.LazyProc) { @@ -972,6 +1317,29 @@ func ensureWindowVisible(stage string, hwnd uintptr, showWindowProc, setWindowPo stage, visible, minimized, rect.Left, rect.Top, rect.Right, rect.Bottom, left, top, width, height)) } +func raiseWindowToFront(hwnd uintptr, showWindowProc, setWindowPosProc, setActiveWindow, setForegroundWindow, bringWindowToTop *syscall.LazyProc) { + if hwnd == 0 { + return + } + if showWindowProc != nil { + showWindowProc.Call(hwnd, swRestore) + } + if setWindowPosProc != nil { + setWindowPosProc.Call(hwnd, hwndTopMostFlag, 0, 0, 0, 0, swpShowFlags) + setWindowPosProc.Call(hwnd, hwndNotTopMostFlag, 0, 0, 0, 0, swpShowFlags) + } + if bringWindowToTop != nil { + bringWindowToTop.Call(hwnd) + } + if setForegroundWindow != nil { + setForegroundWindow.Call(hwnd) + } + if setActiveWindow != nil { + setActiveWindow.Call(hwnd) + } + guiLog("startup raised window to front") +} + func applyTopMost(hwnd uintptr, app *guiApp) { if hwnd == 0 || app.topMost == app.lastTopMost { return @@ -1007,7 +1375,7 @@ func applyFont(app *guiApp) { if sendMessageProc == nil || app.hFont == 0 { return } - targets := []uintptr{app.rightHwnd, app.navBarHwnd} + targets := []uintptr{app.rightHwnd, app.navBarHwnd, app.settingsPaneHwnd, app.settingsExportDirEdit, app.settingsExportBrowseBtn, app.settingsExportCustomEdit, app.settingsExportCustomSaveBtn} for _, hwnd := range targets { if hwnd == 0 { continue @@ -1211,6 +1579,18 @@ func resizeGUIForJoinContent(app *guiApp, contentHeight int) { } } +func resizeGUIForSettings(app *guiApp) { + if app == nil || setWindowPosProc == nil || app.hwnd == 0 { + return + } + resizeFlags := hwndNoMove | hwndNoZOrder | hwndNoActivate + windowHeight := contentTop + leftPaneHeight + bottomBarHeight + setWindowPosProc.Call(app.hwnd, 0, 0, 0, windowWidth, uintptr(windowHeight), resizeFlags) + if app.settingsPaneHwnd != 0 { + setWindowPosProc.Call(app.settingsPaneHwnd, 0, 0, uintptr(contentTop), windowWidth, uintptr(leftPaneHeight), resizeFlags) + } +} + type winRect struct { Left int32 Top int32 @@ -1323,7 +1703,7 @@ func paintMainWindow(hwnd uintptr, app *guiApp) uintptr { fillSolid(hdc, winRect{Left: activeLeft, Top: headerHeight, Right: activeLeft + 120, Bottom: contentTop}, clrNavActiveBg) drawPaneText(hdc, 36, headerHeight+18, "ログ", app.hFont, tabColor(app.activeTab == "join")) drawPaneText(hdc, 154, headerHeight+18, "履歴", app.hFont, tabColor(app.activeTab == "history")) - drawPaneText(hdc, 274, headerHeight+18, "翻訳", app.hFont, tabColor(app.activeTab == "translate")) + drawPaneText(hdc, 274, headerHeight+18, "翻訳", app.hFont, tabLabelColor(app.activeTab == "translate", translateTabEnabled)) drawPaneText(hdc, 394, headerHeight+18, "設定", app.hFont, tabColor(app.activeTab == "settings")) drawLine(hdc, activeLeft, contentTop-3, activeLeft+120, contentTop-3, clrAccentBlue) drawLine(hdc, 0, contentTop, rc.Right, contentTop, clrPaneBorder) @@ -1348,8 +1728,7 @@ func paintFooter(hdc uintptr, rc winRect, app *guiApp) { if users == 0 { users = countPresent(app.currentUsers) } - state := readRuntimeSnapshot() - muted := stateBool(state, "discord_muted") + muted := app.discordMuted drawFilledEllipse(hdc, 28, statusTop+15, 50, statusTop+37, clrAccentBlue) drawPaneText(hdc, 66, statusTop+14, ellipsizeRunes(world, 22), app.hFont, clrText) @@ -1415,6 +1794,12 @@ func paintSettingsPane(hwnd uintptr, app *guiApp) uintptr { if app == nil { return 0 } + start := time.Now() + defer func() { + if d := time.Since(start); d > 80*time.Millisecond { + guiLog(fmt.Sprintf("paintSettingsPane slow=%s", d)) + } + }() hdc, done := beginPanePaint(hwnd) if hdc == 0 { return 0 @@ -1426,22 +1811,25 @@ func paintSettingsPane(hwnd uintptr, app *guiApp) uintptr { drawPaneText(hdc, 24, 20, "設定", app.joinBoldHFont, clrAccentGreen) drawLine(hdc, 24, 50, rc.Right-24, 50, clrPaneBorder) - state := readRuntimeSnapshot() drawPaneText(hdc, 24, 68, "表示", app.hFont, clrMutedText) drawSettingsWideButton(hdc, settingsTopMostRect(), "Top most", onOffBool(app.topMost), app.hFont, app.topMost) drawSettingsWideButton(hdc, settingsAutoUnmuteRect(), "Leave unmute", onOffBool(app.autoUnmuteOnLeave), app.hFont, app.autoUnmuteOnLeave) drawPaneText(hdc, 24, 190, "文字サイズ", app.hFont, clrMutedText) drawSettingsRefreshControl(hdc, app) drawSettingsFontControl(hdc, app) - - y := int32(382) - if updatedAt, _ := state["updated_at"].(string); strings.TrimSpace(updatedAt) != "" { - drawPaneText(hdc, 24, y, "Updated: "+updatedAt, app.hFont, clrMutedText) - y += 26 - } - drawPaneText(hdc, 24, y, "OCR: "+onOffLine(stateString(state, "ocr")), app.hFont, clrText) - y += 26 - drawPaneText(hdc, 24, y, "Translate: "+onOffLine(stateString(state, "translate")), app.hFont, clrText) + drawPaneText(hdc, 24, 390, "履歴 Export", app.hFont, clrMutedText) + drawPaneText(hdc, 24, 414, "出力先フォルダ", app.hFont, clrMutedText) + drawSettingsBox(hdc, settingsHistoryExportDirRect(), 0x0038281a, clrPaneBorder) + drawPaneText(hdc, 120, 414, exportDirDisplayText(app.historyExportDir), app.hFont, clrText) + drawSettingsWideButton(hdc, settingsHistoryExportBrowseRect(), "参照", "", app.hFont, true) + drawSettingsWideButton(hdc, settingsHistoryExportTimeRect(), "Time", onOffBool(app.historyExportIncludeTime), app.hFont, app.historyExportIncludeTime) + drawSettingsWideButton(hdc, settingsHistoryExportWorldRect(), "World", onOffBool(app.historyExportIncludeWorld), app.hFont, app.historyExportIncludeWorld) + drawSettingsWideButton(hdc, settingsHistoryExportJoinLeaveRect(), "Join/Leave", onOffBool(app.historyExportIncludeJoinLeave), app.hFont, app.historyExportIncludeJoinLeave) + drawSettingsWideButton(hdc, settingsHistoryExportCustomToggleRect(), "Custom", onOffBool(app.historyExportCustomEnabled), app.hFont, app.historyExportCustomEnabled) + drawPaneText(hdc, 24, 522, "Custom 条件", app.hFont, clrMutedText) + drawSettingsBox(hdc, settingsHistoryExportCustomRect(), 0x0038281a, clrPaneBorder) + drawPaneText(hdc, 120, 522, previewLine(app.historyExportCustom, 60), app.hFont, clrText) + drawSettingsWideButton(hdc, settingsHistoryExportCustomSaveRect(), "保存", "", app.hFont, true) return 0 } @@ -1481,7 +1869,39 @@ func settingsFontUpRect() winRect { return winRect{Left: 188, Top: 338, Right: 268, Bottom: 374} } -func handleSettingsPaneClick(app *guiApp, x, y int32, setWindowText *syscall.LazyProc) bool { +func settingsHistoryExportDirRect() winRect { + return winRect{Left: 116, Top: 410, Right: 636, Bottom: 434} +} + +func settingsHistoryExportBrowseRect() winRect { + return winRect{Left: 644, Top: 410, Right: 736, Bottom: 434} +} + +func settingsHistoryExportTimeRect() winRect { + return winRect{Left: 24, Top: 442, Right: 360, Bottom: 476} +} + +func settingsHistoryExportWorldRect() winRect { + return winRect{Left: 376, Top: 442, Right: 736, Bottom: 476} +} + +func settingsHistoryExportJoinLeaveRect() winRect { + return winRect{Left: 24, Top: 480, Right: 360, Bottom: 514} +} + +func settingsHistoryExportCustomToggleRect() winRect { + return winRect{Left: 376, Top: 480, Right: 736, Bottom: 514} +} + +func settingsHistoryExportCustomRect() winRect { + return winRect{Left: 116, Top: 520, Right: 636, Bottom: 544} +} + +func settingsHistoryExportCustomSaveRect() winRect { + return winRect{Left: 644, Top: 520, Right: 736, Bottom: 544} +} + +func handleSettingsPaneClick(app *guiApp, x, y int32, getWindowText, setWindowText *syscall.LazyProc) bool { if app == nil || !allowRapidSettingsAction(app) { return true } @@ -1522,6 +1942,26 @@ func handleSettingsPaneClick(app *guiApp, x, y int32, setWindowText *syscall.Laz applyFont(app) saveGUISettings(app) } + case pointInRect(x, y, settingsHistoryExportTimeRect()): + app.historyExportIncludeTime = !app.historyExportIncludeTime + saveGUISettings(app) + case pointInRect(x, y, settingsHistoryExportWorldRect()): + app.historyExportIncludeWorld = !app.historyExportIncludeWorld + saveGUISettings(app) + case pointInRect(x, y, settingsHistoryExportJoinLeaveRect()): + app.historyExportIncludeJoinLeave = !app.historyExportIncludeJoinLeave + saveGUISettings(app) + case pointInRect(x, y, settingsHistoryExportCustomToggleRect()): + app.historyExportCustomEnabled = !app.historyExportCustomEnabled + saveGUISettings(app) + case pointInRect(x, y, settingsHistoryExportBrowseRect()): + if handleHistoryExportBrowseCommand(app, setWindowText) { + return true + } + case pointInRect(x, y, settingsHistoryExportCustomSaveRect()): + if handleHistoryExportSaveCommand(app, getWindowText, setWindowText) { + return true + } default: return false } @@ -1530,15 +1970,6 @@ func handleSettingsPaneClick(app *guiApp, x, y int32, setWindowText *syscall.Laz if app.settingsPaneHwnd != 0 { invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1) } - if app.rightHwnd != 0 { - invalidateRectProc.Call(app.rightHwnd, 0, 1) - } - if app.leftHwnd != 0 { - invalidateRectProc.Call(app.leftHwnd, 0, 1) - } - if app.hwnd != 0 { - invalidateRectProc.Call(app.hwnd, 0, 1) - } } return true } @@ -1852,11 +2283,7 @@ func eventPaneContentHeight(hwnd uintptr, app *guiApp) int32 { } var rc winRect getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) - users := app.currentUsers - if len(users) == 0 { - users, _ = currentUsersFromJoinLeave() - } - events := eventRows(users) + events := joinLeaveRowsForCurrentWorld(app) y := int32(80) for _, ev := range events { nameLines := splitDisplayName(ev.name, 8) @@ -2004,11 +2431,7 @@ func paintEventPane(hwnd uintptr, app *guiApp) uintptr { drawPaneText(hdc, 24, 24, "入退室ログ", app.hFont, clrMutedText) drawLine(hdc, 0, 58, rc.Right, 58, clrPaneBorder) - users := app.currentUsers - if len(users) == 0 { - users, _ = currentUsersFromJoinLeave() - } - events := eventRows(users) + rows := joinLeaveRowsForCurrentWorld(app) pageHeight := rc.Bottom - rc.Top contentHeight := eventPaneContentHeight(hwnd, app) maxPos := scrollMax(contentHeight, pageHeight) @@ -2017,7 +2440,7 @@ func paintEventPane(hwnd uintptr, app *guiApp) uintptr { } applyPaneScroll(hwnd, app.eventScrollPos, contentHeight, pageHeight) y := int32(80 - app.eventScrollPos) - for _, ev := range events { + for _, ev := range rows { if y > rc.Bottom-34 { break } @@ -2247,6 +2670,13 @@ func tabColor(active bool) uint32 { return clrMutedText } +func tabLabelColor(active, enabled bool) uint32 { + if !enabled { + return clrMutedText + } + return tabColor(active) +} + func fillSolid(hdc uintptr, rc winRect, color uint32) { if fillRectProc == nil || createSolidBrushProc == nil { return @@ -2461,6 +2891,46 @@ func eventRows(users []userState) []guiEventRow { return rows } +func joinLeaveRowsForCurrentWorld(app *guiApp) []guiEventRow { + events := historyEventsFromSnapshot() + if len(events) == 0 { + users, _ := currentUsersFromJoinLeave() + return eventRows(users) + } + since := currentWorldSinceFromRuntime() + if since.IsZero() { + since = currentWorldStartTime() + } + rows := make([]guiEventRow, 0, len(events)) + for _, ev := range events { + if ev.At.IsZero() { + continue + } + if !since.IsZero() && ev.At.Before(since) { + continue + } + rows = append(rows, guiEventRow{ + at: ev.At, + name: ev.Name, + join: strings.EqualFold(ev.Kind, "join"), + }) + } + if len(rows) == 0 { + users, _ := currentUsersFromJoinLeave() + return eventRows(users) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].at.Equal(rows[j].at) { + if rows[i].join != rows[j].join { + return rows[i].join + } + return strings.ToLower(rows[i].name) < strings.ToLower(rows[j].name) + } + return rows[i].at.After(rows[j].at) + }) + return rows +} + type guiWorldVisitRow struct { Label string StartedAt time.Time @@ -2508,9 +2978,8 @@ func historyPaneContentHeight(hwnd uintptr, app *guiApp) int32 { var rc winRect getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) rows := app.historyRows - if len(rows) == 0 { - rows = historyRows() - app.historyRows = rows + if rows == nil { + return 120 } contentHeight := int32(84 + len(rows)*38) if contentHeight < 120 { @@ -2533,9 +3002,8 @@ func paintHistoryPane(hwnd uintptr, app *guiApp) uintptr { drawLine(hdc, 0, 58, rc.Right, 58, clrPaneBorder) rows := app.historyRows - if len(rows) == 0 { - rows = historyRows() - app.historyRows = rows + if rows == nil { + rows = []guiWorldVisitRow{} } pageHeight := rc.Bottom - rc.Top contentHeight := historyPaneContentHeight(hwnd, app) @@ -2591,24 +3059,86 @@ type guiWorldHistoryFile struct { func readWorldHistory() []guiWorldVisitRecord { p := filepath.Join(runtimeDir(), "world_history.json") + info, err := os.Stat(p) + if err == nil { + worldHistoryCacheMu.Lock() + if worldHistoryCache.modTime.Equal(info.ModTime()) && worldHistoryCache.size == info.Size() && worldHistoryCache.items != nil { + out := append([]guiWorldVisitRecord(nil), worldHistoryCache.items...) + worldHistoryCacheMu.Unlock() + return out + } + worldHistoryCacheMu.Unlock() + } b, err := os.ReadFile(p) if err != nil || len(b) == 0 { return nil } - var snap guiWorldHistoryFile - if err := json.Unmarshal(b, &snap); err != nil { - var visits []guiWorldVisitRecord - if err := json.Unmarshal(b, &visits); err != nil { + var visits []guiWorldVisitRecord + if err := json.Unmarshal(b, &visits); err != nil { + var snap guiWorldHistoryFile + if err := json.Unmarshal(b, &snap); err != nil { return nil } + visits = snap.Visits + } + out := dedupeWorldHistory(visits) + if info, err := os.Stat(p); err == nil { + worldHistoryCacheMu.Lock() + worldHistoryCache.modTime = info.ModTime() + worldHistoryCache.size = info.Size() + worldHistoryCache.items = append([]guiWorldVisitRecord(nil), out...) + worldHistoryCacheMu.Unlock() + } + return out +} + +func dedupeWorldHistory(visits []guiWorldVisitRecord) []guiWorldVisitRecord { + if len(visits) <= 1 { return visits } - return snap.Visits + type bucket struct { + visit guiWorldVisitRecord + } + seen := make(map[string]int, len(visits)) + out := make([]bucket, 0, len(visits)) + for _, visit := range visits { + key := visit.WorldLabel + "|" + visit.WorldID + "|" + visit.InstanceID + "|" + 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([]guiWorldVisitRecord, 0, len(out)) + for _, item := range out { + merged = append(merged, item.visit) + } + return merged } func currentWorldVisitInfo() (string, time.Time, bool) { - if tailLabel, tailSince := currentWorldVisitFromVRChatLog(); tailLabel != "" && !tailSince.IsZero() { - return tailLabel, tailSince, true + if !vrchatProcessRunning() { + return "", time.Time{}, false } state := readRuntimeSnapshot() label := strings.TrimSpace(stateString(state, "world")) @@ -2621,6 +3151,9 @@ func currentWorldVisitInfo() (string, time.Time, bool) { if label != "" { return label, time.Now(), true } + if tailLabel, tailSince := currentWorldVisitFromVRChatLog(); tailLabel != "" && !tailSince.IsZero() { + return tailLabel, tailSince, true + } return "", time.Time{}, false } @@ -2780,87 +3313,87 @@ func allowRapidTabAction(app *guiApp) bool { } func currentUsersFromJoinLeave() ([]userState, int) { + if out, ok := currentUsersFromGuestSnapshot(); ok { + guiLog(fmt.Sprintf("currentUsers source=guest_snapshot count=%d total=%d", countPresent(out), len(out))) + return out, countPresent(out) + } + + if out, ok := currentUsersFromJoinLeaveSnapshot(); ok { + guiLog(fmt.Sprintf("currentUsers source=join_leave_snapshot count=%d total=%d", countPresent(out), len(out))) + return out, countPresent(out) + } + if out, ok := currentUsersFromVRChatLog(); ok { guiLog(fmt.Sprintf("currentUsers source=vrc_log count=%d total=%d", countPresent(out), len(out))) return out, countPresent(out) } - if out, ok := currentUsersFromJoinLeaveLog(); ok { - guiLog(fmt.Sprintf("currentUsers source=join_leave count=%d total=%d", countPresent(out), len(out))) - return out, countPresent(out) - } - - if snap := readGuestSnapshot(); len(snap) > 0 { - out := make([]userState, 0, len(snap)) - for _, s := range snap { - out = append(out, userState{ - Name: s.Name, - Present: s.Present, - LastJoin: s.LastJoin, - LastLeave: s.LastLeave, - }) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Present != out[j].Present { - return out[i].Present - } - if out[i].LastLeave.Equal(out[j].LastLeave) { - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) - } - return out[i].LastLeave.After(out[j].LastLeave) - }) - guiLog(fmt.Sprintf("currentUsers source=snapshot count=%d total=%d", countPresent(out), len(out))) - return out, countPresent(out) - } - guiLog("currentUsers source=none") return nil, 0 } -func currentUsersFromJoinLeaveLog() ([]userState, bool) { - p := filepath.Join(runtimeDir(), "join_leave.log") - b, err := readFileTail(p, guiLogTailBytes) - if err != nil || len(b) == 0 { +func currentUsersFromGuestSnapshot() ([]userState, bool) { + snap := readGuestSnapshot() + if len(snap) == 0 { + return nil, false + } + out := make([]userState, 0, len(snap)) + for _, s := range snap { + out = append(out, userState{ + Name: s.Name, + Present: s.Present, + LastJoin: s.LastJoin, + LastLeave: s.LastLeave, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Present != out[j].Present { + return out[i].Present + } + if out[i].LastLeave.Equal(out[j].LastLeave) { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + } + return out[i].LastLeave.After(out[j].LastLeave) + }) + return out, true +} + +func currentUsersFromJoinLeaveSnapshot() ([]userState, bool) { + since := currentWorldSinceFromRuntime() + if since.IsZero() { + return nil, false + } + events := readJoinLeaveEventsSnapshot() + if len(events) == 0 { return nil, false } users := map[string]*userState{} - var entryAt time.Time - lines := strings.Split(string(b), "\n") - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "VRC JOIN/LEAVE") { + for _, ev := range events { + if ev.At.IsZero() || ev.At.Before(since) { continue } - if at, ok := parseRuntimeTime(line); ok && strings.Contains(line, "VRC JOIN/LEAVE") { - entryAt = at + name := strings.TrimSpace(ev.Name) + if name == "" { continue } - m := joinLeaveBodyPattern.FindStringSubmatch(line) - if len(m) != 4 { - continue - } - kind := m[1] - name := strings.TrimSpace(m[2]) - at := entryAt u := users[name] if u == nil { u = &userState{Name: name} users[name] = u } - switch kind { + switch ev.Kind { case "join": u.Present = true - if !at.IsZero() { - u.LastJoin = at - } + u.LastJoin = ev.At case "leave": u.Present = false - if !at.IsZero() { - u.LastLeave = at - } + u.LastLeave = ev.At } } - return sortUserStates(users), len(users) > 0 + if len(users) == 0 { + return nil, false + } + return sortUserStates(users), true } func currentUsersFromVRChatLog() ([]userState, bool) { @@ -2917,6 +3450,130 @@ func currentUsersFromVRChatLog() ([]userState, bool) { return sortUserStates(users), len(users) > 0 } +func currentWorldSinceFromRuntime() time.Time { + if !vrchatProcessRunning() { + return time.Time{} + } + state := readRuntimeSnapshot() + sinceText := strings.TrimSpace(stateString(state, "world_since")) + if sinceText == "" { + return time.Time{} + } + since, err := time.Parse(time.RFC3339, sinceText) + if err != nil { + return time.Time{} + } + return since +} + +func readJoinLeaveEventsSnapshot() []joinLeaveEventSnapshot { + jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json") + logPath := filepath.Join(runtimeDir(), "join_leave.log") + jsonInfo, jsonErr := os.Stat(jsonPath) + logInfo, logErr := os.Stat(logPath) + if jsonErr == nil || logErr == nil { + joinLeaveEventsCacheMu.Lock() + if joinLeaveEventsCache.items != nil && + ((jsonErr == nil && joinLeaveEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && joinLeaveEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) && + ((logErr == nil && joinLeaveEventsCache.logMod.Equal(logInfo.ModTime()) && joinLeaveEventsCache.logSize == logInfo.Size()) || logErr != nil) { + out := append([]joinLeaveEventSnapshot(nil), joinLeaveEventsCache.items...) + joinLeaveEventsCacheMu.Unlock() + return out + } + joinLeaveEventsCacheMu.Unlock() + } + if events := readJoinLeaveEventsSnapshotFromJSON(jsonPath); len(events) > 0 { + if jsonErr == nil { + joinLeaveEventsCacheMu.Lock() + joinLeaveEventsCache.jsonMod = jsonInfo.ModTime() + joinLeaveEventsCache.jsonSize = jsonInfo.Size() + joinLeaveEventsCache.logMod = time.Time{} + joinLeaveEventsCache.logSize = 0 + joinLeaveEventsCache.items = append([]joinLeaveEventSnapshot(nil), events...) + joinLeaveEventsCacheMu.Unlock() + } + return events + } + events := readJoinLeaveEventsSnapshotFromLog(logPath) + if logErr == nil { + joinLeaveEventsCacheMu.Lock() + joinLeaveEventsCache.jsonMod = time.Time{} + joinLeaveEventsCache.jsonSize = 0 + joinLeaveEventsCache.logMod = logInfo.ModTime() + joinLeaveEventsCache.logSize = logInfo.Size() + joinLeaveEventsCache.items = append([]joinLeaveEventSnapshot(nil), events...) + joinLeaveEventsCacheMu.Unlock() + } + return events +} + +func readJoinLeaveEventsSnapshotFromJSON(path string) []joinLeaveEventSnapshot { + b, err := os.ReadFile(path) + if err != nil || len(b) == 0 { + return nil + } + type snapshot struct { + UpdatedAt time.Time `json:"updated_at"` + Events []struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + Name string `json:"name"` + Count int `json:"count"` + } `json:"events"` + } + var snap snapshot + if err := json.Unmarshal(b, &snap); err != nil || len(snap.Events) == 0 { + var flat []joinLeaveEventSnapshot + if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 { + return flat + } + return nil + } + out := make([]joinLeaveEventSnapshot, 0, len(snap.Events)) + for _, ev := range snap.Events { + out = append(out, joinLeaveEventSnapshot{ + At: ev.At, + Kind: ev.Kind, + Name: ev.Name, + Count: ev.Count, + }) + } + return out +} + +func readJoinLeaveEventsSnapshotFromLog(path string) []joinLeaveEventSnapshot { + b, err := os.ReadFile(path) + if err != nil || len(b) == 0 { + return nil + } + events := make([]joinLeaveEventSnapshot, 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 at, ok := parseRuntimeTime(line); ok { + currentAt = at + } + continue + } + m := joinLeaveBodyPattern.FindStringSubmatch(line) + if len(m) != 4 { + continue + } + count, _ := strconv.Atoi(m[3]) + events = append(events, joinLeaveEventSnapshot{ + At: currentAt, + Kind: m[1], + Name: strings.TrimSpace(m[2]), + Count: count, + }) + } + return events +} + func sortUserStates(users map[string]*userState) []userState { out := make([]userState, 0, len(users)) for _, u := range users { @@ -2982,6 +3639,16 @@ func findLatestVRChatLog() (string, error) { func readGuestSnapshot() []appPkg.GuestStatus { p := filepath.Join(runtimeDir(), "guest_snapshot.json") + info, err := os.Stat(p) + if err == nil { + guestSnapshotCacheMu.Lock() + if guestSnapshotCache.modTime.Equal(info.ModTime()) && guestSnapshotCache.size == info.Size() && guestSnapshotCache.items != nil { + out := append([]appPkg.GuestStatus(nil), guestSnapshotCache.items...) + guestSnapshotCacheMu.Unlock() + return out + } + guestSnapshotCacheMu.Unlock() + } b, err := readFileTail(p, guiLogTailBytes) if err != nil { return nil @@ -2990,6 +3657,13 @@ func readGuestSnapshot() []appPkg.GuestStatus { if err := json.Unmarshal(b, &out); err != nil { return nil } + if info, err := os.Stat(p); err == nil { + guestSnapshotCacheMu.Lock() + guestSnapshotCache.modTime = info.ModTime() + guestSnapshotCache.size = info.Size() + guestSnapshotCache.items = append([]appPkg.GuestStatus(nil), out...) + guestSnapshotCacheMu.Unlock() + } return out } @@ -3060,7 +3734,15 @@ func currentSelfName() string { func loadGUISettings() config.GUIConfig { cfg, err := config.Load("") if err != nil || cfg == nil { - return config.GUIConfig{TopMost: false, FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"} + return config.GUIConfig{ + TopMost: false, + FontSize: 18, + RefreshIntervalValue: 2, + RefreshIntervalUnit: "sec", + HistoryExportIncludeTime: true, + HistoryExportIncludeWorld: true, + HistoryExportIncludeJoinLeave: true, + } } if cfg.GUI.FontSize <= 0 { cfg.GUI.FontSize = 18 @@ -3069,6 +3751,8 @@ func loadGUISettings() config.GUIConfig { cfg.GUI.HistoryRegex = strings.TrimSpace(cfg.GUI.HistoryRegex) cfg.GUI.HistoryFromDate = strings.TrimSpace(cfg.GUI.HistoryFromDate) cfg.GUI.HistoryToDate = strings.TrimSpace(cfg.GUI.HistoryToDate) + cfg.GUI.HistoryExportDir = strings.TrimSpace(cfg.GUI.HistoryExportDir) + cfg.GUI.HistoryExportCustom = strings.TrimSpace(cfg.GUI.HistoryExportCustom) return cfg.GUI } @@ -3076,15 +3760,22 @@ func saveGUISettings(app *guiApp) { if app == nil { return } + app.historyExportDir = normalizeHistoryExportDir(app.historyExportDir) guiCfg := config.GUIConfig{ - TopMost: app.topMost, - FontSize: app.fontSize, - AutoUnmuteOnSelfLeave: app.autoUnmuteOnLeave, - RefreshIntervalValue: app.refreshIntervalValue, - RefreshIntervalUnit: app.refreshIntervalUnit, - HistoryRegex: strings.TrimSpace(app.historyRegex), - HistoryFromDate: strings.TrimSpace(app.historyFromDate), - HistoryToDate: strings.TrimSpace(app.historyToDate), + TopMost: app.topMost, + FontSize: app.fontSize, + AutoUnmuteOnSelfLeave: app.autoUnmuteOnLeave, + RefreshIntervalValue: app.refreshIntervalValue, + RefreshIntervalUnit: app.refreshIntervalUnit, + HistoryRegex: strings.TrimSpace(app.historyRegex), + HistoryFromDate: strings.TrimSpace(app.historyFromDate), + HistoryToDate: strings.TrimSpace(app.historyToDate), + HistoryExportDir: strings.TrimSpace(app.historyExportDir), + HistoryExportIncludeTime: app.historyExportIncludeTime, + HistoryExportIncludeWorld: app.historyExportIncludeWorld, + HistoryExportIncludeJoinLeave: app.historyExportIncludeJoinLeave, + HistoryExportCustomEnabled: app.historyExportCustomEnabled, + HistoryExportCustom: strings.TrimSpace(app.historyExportCustom), } if err := config.SaveGUI("", guiCfg); err != nil { guiLog("saveGUISettings failed: " + err.Error()) @@ -3093,6 +3784,295 @@ func saveGUISettings(app *guiApp) { guiLog("saveGUISettings ok") } +func syncHistoryExportSettingsFromControls(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) { + if app == nil { + return + } + if app.settingsEditSyncing { + return + } + app.historyExportDir = normalizeHistoryExportDir(app.historyExportDir) + app.historyExportCustom = strings.TrimSpace(app.historyExportCustom) + if getWindowText != nil { + app.historyExportDir = normalizeHistoryExportDir(readWindowText(getWindowText, app.settingsExportDirEdit)) + app.historyExportCustom = strings.TrimSpace(readWindowText(getWindowText, app.settingsExportCustomEdit)) + } + saveGUISettings(app) + if setWindowText != nil { + syncHistoryExportEditControls(app, setWindowText) + } +} + +func startHistoryExport(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) bool { + if app == nil { + return false + } + if app.historyExportRunning { + app.historyStatus = "exporting..." + invalidateHistoryPanes(app) + return true + } + req := historyExportRequest{ + Day: app.historySelectedDate, + Selected: cloneHistorySelection(app.historySelectedKeys), + Rows: cloneHistoryDetailRows(app.historyDetailRows), + ExportDir: app.historyExportDir, + IncludeTime: app.historyExportIncludeTime, + IncludeWorld: app.historyExportIncludeWorld, + IncludeJoinLeave: app.historyExportIncludeJoinLeave, + CustomEnabled: app.historyExportCustomEnabled, + Custom: strings.TrimSpace(app.historyExportCustom), + RegexText: strings.TrimSpace(app.historyRegex), + Query: historyQueryFromApp(app), + } + if req.Day.IsZero() { + req.Day = time.Now() + } + if app.historyExportResultCh == nil { + app.historyExportResultCh = make(chan historyExportResult, 1) + } + guiLog(fmt.Sprintf("history export start day=%s dir=%s selected=%d", req.Day.Format("2006-01-02"), req.ExportDir, len(req.Selected))) + app.historyExportRunning = true + app.historyStatus = "exporting..." + invalidateHistoryPanes(app) + hwnd := app.hwnd + go func(ch chan<- historyExportResult, req historyExportRequest, hwnd uintptr) { + defer func() { + if r := recover(); r != nil { + result := historyExportResult{ + Err: fmt.Errorf("export panic: %v", r), + Day: req.Day, + SelectedCount: len(req.Selected), + } + select { + case ch <- result: + default: + } + if postMessageProc != nil && hwnd != 0 { + postMessageProc.Call(hwnd, uintptr(wmHistoryExportDone), 0, 0) + } + } + }() + path, err := exportHistoryDayFromRequest(req) + result := historyExportResult{ + Path: path, + Err: err, + Day: req.Day, + SelectedCount: len(req.Selected), + } + select { + case ch <- result: + default: + } + if postMessageProc != nil && hwnd != 0 { + postMessageProc.Call(hwnd, uintptr(wmHistoryExportDone), 0, 0) + } + }(app.historyExportResultCh, req, hwnd) + return true +} + +func drainHistoryExportResult(app *guiApp) bool { + if app == nil || app.historyExportResultCh == nil || !app.historyExportRunning { + return false + } + handled := false + for { + select { + case res := <-app.historyExportResultCh: + handled = true + app.historyExportRunning = false + if res.Err != nil { + app.historyStatus = "export failed: " + res.Err.Error() + guiLog(fmt.Sprintf("history export failed day=%s err=%v", res.Day.Format("2006-01-02"), res.Err)) + } else if res.Path != "" { + app.historyStatus = "exported: " + filepath.Base(res.Path) + guiLog(fmt.Sprintf("history export ok day=%s path=%s selected=%d", res.Day.Format("2006-01-02"), res.Path, res.SelectedCount)) + if dir := strings.TrimSpace(filepath.Dir(res.Path)); dir != "" { + if ok := openFolderInExplorer(dir); ok { + guiLog("history export folder opened: " + dir) + } else { + guiLog("history export folder open failed: " + dir) + } + } + } else { + app.historyStatus = "exported: nothing selected" + guiLog(fmt.Sprintf("history export skipped day=%s selected=%d", res.Day.Format("2006-01-02"), res.SelectedCount)) + } + invalidateHistoryPanes(app) + default: + return handled + } + } +} + +func openFolderInExplorer(path string) bool { + path = strings.TrimSpace(path) + if path == "" || shellExecuteProc == nil { + return false + } + op, _ := syscall.UTF16PtrFromString("open") + target, err := syscall.UTF16PtrFromString(path) + if err != nil { + return false + } + r, _, _ := shellExecuteProc.Call(0, uintptr(unsafe.Pointer(op)), uintptr(unsafe.Pointer(target)), 0, 0, 1) + return r > 32 +} + +func syncHistoryExportEditControls(app *guiApp, setWindowText *syscall.LazyProc) { + if app == nil || setWindowText == nil || app.settingsEditSyncing { + return + } + app.settingsEditSyncing = true + defer func() { + app.settingsEditSyncing = false + }() + if app.settingsExportDirEdit != 0 { + text, _ := syscall.UTF16PtrFromString(normalizeHistoryExportDir(app.historyExportDir)) + setWindowText.Call(app.settingsExportDirEdit, uintptr(unsafe.Pointer(text))) + } + if app.settingsExportCustomEdit != 0 { + text, _ := syscall.UTF16PtrFromString(app.historyExportCustom) + setWindowText.Call(app.settingsExportCustomEdit, uintptr(unsafe.Pointer(text))) + } +} + +func handleHistoryExportBrowseCommand(app *guiApp, setWindowText *syscall.LazyProc) bool { + if app == nil { + return false + } + path, ok := browseForFolderPath(app.hwnd, "履歴 export フォルダを選択", app.historyExportDir) + if !ok || strings.TrimSpace(path) == "" { + return true + } + app.historyExportDir = normalizeHistoryExportDir(path) + saveGUISettings(app) + if setWindowText != nil { + syncHistoryExportEditControls(app, setWindowText) + } + if invalidateRectProc != nil && app.settingsPaneHwnd != 0 { + invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1) + } + return true +} + +func handleHistoryExportSaveCommand(app *guiApp, getWindowText, setWindowText *syscall.LazyProc) bool { + if app == nil { + return false + } + syncHistoryExportSettingsFromControls(app, getWindowText, nil) + saveGUISettings(app) + if invalidateRectProc != nil && app.settingsPaneHwnd != 0 { + invalidateRectProc.Call(app.settingsPaneHwnd, 0, 1) + } + return true +} + +func browseForFolderPath(owner uintptr, title, initial string) (string, bool) { + shell32 := syscall.NewLazyDLL("shell32.dll") + ole32 := syscall.NewLazyDLL("ole32.dll") + browseForFolder := shell32.NewProc("SHBrowseForFolderW") + shGetPathFromIDList := shell32.NewProc("SHGetPathFromIDListW") + coTaskMemFree := ole32.NewProc("CoTaskMemFree") + + initial = normalizeHistoryExportDir(initial) + if initial == "" { + initial = filepath.Join(runtimeDir(), "exports") + } + titlePtr, _ := syscall.UTF16PtrFromString(title) + initialPtr, _ := syscall.UTF16PtrFromString(initial) + displayName := make([]uint16, 260) + callback := syscall.NewCallback(func(hwnd uintptr, msg, lParam, lpData uintptr) uintptr { + if msg == bffmInitialized && lParam != 0 && sendMessageProc != nil { + sendMessageProc.Call(hwnd, bffmSetSelectionW, 1, lParam) + } + return 0 + }) + type browseInfo struct { + HwndOwner uintptr + PidlRoot uintptr + PszDisplayName *uint16 + LpszTitle *uint16 + UlFlags uint32 + Lpfn uintptr + LParam uintptr + IImage int32 + } + bi := browseInfo{ + HwndOwner: owner, + PszDisplayName: &displayName[0], + LpszTitle: titlePtr, + UlFlags: bifReturnOnlyFsDirs | bifEditBox | bifNewDialogStyle, + Lpfn: callback, + LParam: uintptr(unsafe.Pointer(initialPtr)), + } + pidl, _, _ := browseForFolder.Call(uintptr(unsafe.Pointer(&bi))) + if pidl == 0 { + return "", false + } + defer func() { + if coTaskMemFree != nil { + coTaskMemFree.Call(pidl) + } + }() + pathBuf := make([]uint16, 260) + ok, _, _ := shGetPathFromIDList.Call(pidl, uintptr(unsafe.Pointer(&pathBuf[0]))) + if ok == 0 { + return "", false + } + return strings.TrimSpace(syscall.UTF16ToString(pathBuf)), true +} + +func handleHistoryExportEditCommand(app *guiApp, cmd uint16, notify uint16, getWindowText, setWindowText *syscall.LazyProc) bool { + if app == nil { + return false + } + if cmd != idHistoryExportDir && cmd != idHistoryExportCustom { + return false + } + if app.settingsEditSyncing { + return true + } + if notify != enKillFocus && notify != enChange { + return true + } + if notify == enChange { + return true + } + syncHistoryExportSettingsFromControls(app, getWindowText, nil) + return true +} + +func normalizeHistoryExportDir(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", "")) + if value == "" { + return filepath.Join(runtimeDir(), "exports") + } + value = strings.ReplaceAll(value, "/", `\`) + if strings.HasPrefix(value, `\\?\`) { + return filepath.Clean(value) + } + if strings.HasPrefix(value, `\\`) { + rest := strings.TrimLeft(value[2:], `\`) + for strings.Contains(rest, `\\`) { + rest = strings.ReplaceAll(rest, `\\`, `\`) + } + return filepath.Clean(`\\` + rest) + } + for strings.Contains(value, `\\`) { + value = strings.ReplaceAll(value, `\\`, `\`) + } + value = filepath.Clean(value) + if value == "." { + return filepath.Join(runtimeDir(), "exports") + } + return value +} + +func exportDirDisplayText(value string) string { + return previewLine(normalizeHistoryExportDir(value), 60) +} + func formatRightPane(tab string, state map[string]any, worldLabel string, currentUsers int, current []userState, historyCount int) string { var b strings.Builder switch tab { @@ -3165,8 +4145,6 @@ func formatSettingsPane(state map[string]any) string { } b.WriteString("\r\nOCR: ") b.WriteString(onOffLine(stateString(state, "ocr"))) - b.WriteString("\r\nTranslate: ") - b.WriteString(onOffLine(stateString(state, "translate"))) return b.String() } @@ -3233,6 +4211,22 @@ func previewLine(text string, limit int) string { return clean } +func readWindowText(getWindowText *syscall.LazyProc, hwnd uintptr) string { + if getWindowText == nil || hwnd == 0 { + return "" + } + buf := make([]uint16, 1024) + n, _, _ := getWindowText.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if n == 0 { + return "" + } + size := int(n) + if size > len(buf) { + size = len(buf) + } + return strings.TrimSpace(syscall.UTF16ToString(buf[:size])) +} + func stateBool(state map[string]any, key string) bool { v, _ := state[key].(bool) return v @@ -3243,7 +4237,7 @@ func currentWorldLabel() string { return world } p := filepath.Join(runtimeDir(), "runtime.log") - b, err := os.ReadFile(p) + b, err := readFileTail(p, guiLogTailBytes) latest := "" if err == nil { lines := strings.Split(string(b), "\n") @@ -3266,6 +4260,57 @@ func currentWorldLabel() string { return "" } +func vrchatProcessRunning() bool { + vrchatRunningMu.Lock() + defer vrchatRunningMu.Unlock() + now := time.Now() + if !vrchatRunningLastCheck.IsZero() && now.Sub(vrchatRunningLastCheck) < 2*time.Second { + return vrchatRunningLastValue + } + vrchatRunningLastCheck = now + vrchatRunningLastValue = false + kernel32 := syscall.NewLazyDLL("kernel32.dll") + createToolhelp32Snapshot := kernel32.NewProc("CreateToolhelp32Snapshot") + process32FirstW := kernel32.NewProc("Process32FirstW") + process32NextW := kernel32.NewProc("Process32NextW") + closeHandle := kernel32.NewProc("CloseHandle") + const th32CSSnapProcess = 0x00000002 + type processEntry32 struct { + dwSize uint32 + cntUsage uint32 + th32ProcessID uint32 + th32DefaultHeapID uintptr + th32ModuleID uint32 + cntThreads uint32 + th32ParentProcessID uint32 + pcPriClassBase int32 + dwFlags uint32 + szExeFile [260]uint16 + } + snapshot, _, _ := createToolhelp32Snapshot.Call(th32CSSnapProcess, 0) + if snapshot == 0 || snapshot == ^uintptr(0) { + return false + } + defer closeHandle.Call(snapshot) + var entry processEntry32 + entry.dwSize = uint32(unsafe.Sizeof(entry)) + r1, _, _ := process32FirstW.Call(snapshot, uintptr(unsafe.Pointer(&entry))) + if r1 == 0 { + return false + } + for { + if strings.EqualFold(syscall.UTF16ToString(entry.szExeFile[:]), "VRChat.exe") { + vrchatRunningLastValue = true + return true + } + r1, _, _ = process32NextW.Call(snapshot, uintptr(unsafe.Pointer(&entry))) + if r1 == 0 { + break + } + } + return vrchatRunningLastValue +} + func currentWorldFromVRChatLog() string { label, _ := currentWorldVisitFromVRChatLog() return label @@ -3350,7 +4395,9 @@ func readRuntimeSnapshot() map[string]any { return map[string]any{} } var out map[string]any - _ = json.Unmarshal(b, &out) + if err := json.Unmarshal(b, &out); err != nil || out == nil { + return map[string]any{} + } return out } diff --git a/cmd/vrc_osc_launcher/rsrc_windows_amd64.syso b/cmd/vrc_osc_launcher/rsrc_windows_amd64.syso new file mode 100644 index 0000000..7223f32 Binary files /dev/null and b/cmd/vrc_osc_launcher/rsrc_windows_amd64.syso differ diff --git a/config/config.example.toml b/config/config.example.toml index e3e8293..bbd1ae3 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -10,6 +10,15 @@ file = "config/guests.txt" [gui] top_most = false font_size = 18 +auto_unmute_on_self_leave = false +refresh_interval_value = 2 +refresh_interval_unit = "sec" +history_export_dir = "exports" +history_export_include_time = true +history_export_include_world = true +history_export_include_join_leave = true +history_export_custom_enabled = false +history_export_custom = "" [notice] missing_count = 0 diff --git a/doc/vrcworldtour.png b/doc/vrcworldtour.png new file mode 100644 index 0000000..16087db Binary files /dev/null and b/doc/vrcworldtour.png differ diff --git a/doc/vrcworldtour_new.ico b/doc/vrcworldtour_new.ico new file mode 100644 index 0000000..d99fd2b Binary files /dev/null and b/doc/vrcworldtour_new.ico differ diff --git a/doc/vrcworldtour_new.png b/doc/vrcworldtour_new.png new file mode 100644 index 0000000..981b1f6 Binary files /dev/null and b/doc/vrcworldtour_new.png differ diff --git a/doc/workflow.md b/doc/workflow.md index 3f5abd0..5a7ef32 100644 --- a/doc/workflow.md +++ b/doc/workflow.md @@ -1,70 +1,78 @@ -# ワークフロー +# Workflow -## 起動から利用まで +## 全体の流れ ```mermaid flowchart TD - A[起動] --> B[config を読む] - B --> C[OSC サーバーを起動] - C --> D[VRChat を開く] - D --> E[ExMenu から機能を選ぶ] - E --> F{どの機能か} + A[アプリ起動] --> B[設定読み込み] + B --> C[VRChat ログの探索] + C --> D[GUI 起動] + D --> E[タイマーで定期更新] + E --> F[現在のワールド判定] + E --> G[入退室ログ更新] + E --> H[履歴データ更新] + E --> I[OCR / 翻訳 / 設定反映] - F -->|DiscordSend| G[Discord ミュート操作] - F -->|vrc_log| H[VRC ログ監視] - F -->|ocrEnabled| I[OCR 実行] - F -->|translation| J[OCR + 翻訳] - - G --> K[VRChat に戻る] - H --> L[Discord Webhook に通知] - I --> M[runtime に保存] - J --> N[クリップボードへコピー] - J --> M + G --> J[Discord 送信が必要なら通知] + H --> K[日別履歴表示] + K --> L[選択した訪問を export] + L --> M[visit_YYYYMMDD.log を保存] ``` -## DiscordSend の流れ +## 起動時 -```mermaid -flowchart TD - A[DiscordSend を受信] --> B[Discord ウィンドウを探す] - B --> C{Discord が見つかる?} - C -->|はい| D[Ctrl+Shift+M を送る] - C -->|いいえ| E[ブラウザ版 Discord を探す] - E --> D - D --> F[VRChat に戻す] +1. `config/config.toml` を読み込む。 +2. GUI 設定を反映する。 +3. `runtime/` 配下のログや履歴を確認する。 +4. ネイティブ GUI を開く。 +5. タイマーで定期更新を開始する。 + +## ログ収集 + +1. VRChat の最新ログを探す。 +2. ログの末尾を読み、`OnPlayerJoined` / `OnPlayerLeft` / `Entering Room` / `worldId=` などを拾う。 +3. 現在のワールド名、人数、入退室イベントを更新する。 +4. 必要なら `runtime/join_leave.log` に追記する。 +5. 履歴用の `runtime/world_history.json` を参照して、訪問区間を組み立てる。 + +## GUI 更新 + +1. 画面はタブごとに描画を切り替える。 +2. ログタブは現在の入退室状況を表示する。 +3. 翻訳タブは OCR と翻訳結果を表示する。 +4. 設定タブは `top most`、文字サイズ、更新間隔、履歴条件を変更できる。 +5. 履歴タブは日付カレンダーと、その日の訪問一覧を表示する。 + +## 履歴タブ + +1. 設定で `history_regex`、`history_from`、`history_to` を指定する。 +2. カレンダーから日付を選ぶ。 +3. その日の訪問ワールド一覧を出す。 +4. 訪問を複数選択できる。 +5. `Export` を押すと `runtime/visit_YYYYMMDD.log` を出力する。 +6. 出力内容は選択した訪問のワールド名と、条件に一致した入退室行になる。 + +## エクスポート例 + +```text +Date: 2026-06-29 +Filter: POPPOHOUSE|Holiday-Cottage + +World: POPPOHOUSE +[22:15] join ちりー@ +[22:15] join ゆらいか + +World: Holiday-Cottage +[20:47] join 毎日がHoliday' +[20:48] leave 毎日がHoliday' ``` -## vrc_log の流れ +## 保存されるもの -```mermaid -flowchart TD - A[vrc_log を受信] --> B[ログ監視 ON/OFF を切り替える] - B --> C{ON か?} - C -->|はい| D[最新ログを読む] - D --> E[入室 / 退室 / ワールド変更を解析] - E --> F{通知対象か?} - F -->|はい| G[Discord Webhook に送る] - F -->|いいえ| H[保存だけする] - C -->|いいえ| I[監視を停止する] -``` +- `runtime/runtime.log` +- `runtime/join_leave.log` +- `runtime/world_history.json` +- `runtime/visit_YYYYMMDD.log` +- `runtime/latest_ocr.png` +- `runtime/latest_screenshot.png` -## ocrEnabled の流れ - -```mermaid -flowchart TD - A[ocrEnabled を受信] --> B[VRChat 画面を読み取る] - B --> C[OCR を実行] - C --> D[認識結果を保存] -``` - -## translation の流れ - -```mermaid -flowchart TD - A[translation を受信] --> B[VRChat 画面を読み取る] - B --> C[OCR を実行] - C --> D[不要な文字を除去] - D --> E[日本語へ翻訳] - E --> F[翻訳結果をクリップボードへコピー] - E --> G[runtime に保存] -``` diff --git a/internal/app/app.go b/internal/app/app.go index 8ca0b1a..47a4797 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 } diff --git a/internal/app/discord.go b/internal/app/discord.go index c77ac63..39bd677 100644 --- a/internal/app/discord.go +++ b/internal/app/discord.go @@ -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 } diff --git a/internal/app/discord_mute.go b/internal/app/discord_mute.go index 31cb5e5..75020eb 100644 --- a/internal/app/discord_mute.go +++ b/internal/app/discord_mute.go @@ -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': diff --git a/internal/app/guest_status.go b/internal/app/guest_status.go index 15139fe..23a553a 100644 --- a/internal/app/guest_status.go +++ b/internal/app/guest_status.go @@ -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() diff --git a/internal/app/join_leave_events.go b/internal/app/join_leave_events.go new file mode 100644 index 0000000..38d5f08 --- /dev/null +++ b/internal/app/join_leave_events.go @@ -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 +} diff --git a/internal/app/join_leave_events_test.go b/internal/app/join_leave_events_test.go new file mode 100644 index 0000000..bfca906 --- /dev/null +++ b/internal/app/join_leave_events_test.go @@ -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]) + } +} diff --git a/internal/app/refresh_interval.go b/internal/app/refresh_interval.go new file mode 100644 index 0000000..95bafbf --- /dev/null +++ b/internal/app/refresh_interval.go @@ -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) +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 50894e3..41c7b77 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -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 { diff --git a/internal/app/self_monitor.go b/internal/app/self_monitor.go index be5a32a..280074b 100644 --- a/internal/app/self_monitor.go +++ b/internal/app/self_monitor.go @@ -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)) } }() } diff --git a/internal/app/state.go b/internal/app/state.go index be072d1..af39a3a 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -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), }) } diff --git a/internal/app/vrc_log.go b/internal/app/vrc_log.go index 6562338..9cb7132 100644 --- a/internal/app/vrc_log.go +++ b/internal/app/vrc_log.go @@ -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) + } +} diff --git a/internal/app/vrc_log_test.go b/internal/app/vrc_log_test.go new file mode 100644 index 0000000..453573a --- /dev/null +++ b/internal/app/vrc_log_test.go @@ -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) + } +} diff --git a/internal/app/world_history.go b/internal/app/world_history.go new file mode 100644 index 0000000..7f2040d --- /dev/null +++ b/internal/app/world_history.go @@ -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)" +} diff --git a/internal/config/config.go b/internal/config/config.go index c98b7b6..5ace65e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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)), ) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3c453b4..306cda9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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)