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)