Compare commits
3 Commits
v0.1.1
...
63700d9d29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63700d9d29 | ||
|
|
ab9551c241 | ||
|
|
b6421958af |
BIN
VRWT_Tool/VRC_OSC/assets/icon.ico
Normal file
BIN
VRWT_Tool/VRC_OSC/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
VRWT_Tool/VRC_OSC/assets/icon.png
Normal file
BIN
VRWT_Tool/VRC_OSC/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -4,6 +4,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"vrc_osc_go/internal/app"
|
"vrc_osc_go/internal/app"
|
||||||
)
|
)
|
||||||
@@ -11,12 +13,30 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
var mode string
|
var mode string
|
||||||
|
var noGUI bool
|
||||||
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
|
||||||
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
|
||||||
|
flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if mode == "" && !noGUI {
|
||||||
|
launchGUI()
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Run(configPath, mode); err != nil {
|
if err := app.Run(configPath, mode); err != nil {
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.Fatalf("vrc_osc_go: %v", err)
|
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()
|
||||||
|
}
|
||||||
|
|||||||
198
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal file
198
VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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 := `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="3">
|
||||||
|
<title>VRTW_Tool</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:sans-serif;background:#111;color:#eee;margin:20px}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.card{background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
td,th{border-bottom:1px solid #333;padding:6px;text-align:left}
|
||||||
|
th{cursor:pointer;user-select:none}
|
||||||
|
th:hover{color:#8ff}
|
||||||
|
.on{color:#5ff28f}.off{color:#ff8a8a}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>VRTW_Tool</h1>
|
||||||
|
<div id="root">loading...</div>
|
||||||
|
<script>
|
||||||
|
const sortKey = 'vrc_osc_gui_sort';
|
||||||
|
const defaultSort = { key: 'name', dir: 1 };
|
||||||
|
let sortState = (() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(sortKey);
|
||||||
|
return raw ? JSON.parse(raw) : defaultSort;
|
||||||
|
} catch (_) {
|
||||||
|
return defaultSort;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setSort(key) {
|
||||||
|
if (sortState.key === key) {
|
||||||
|
sortState.dir = sortState.dir * -1;
|
||||||
|
} else {
|
||||||
|
sortState = { key: key, dir: 1 };
|
||||||
|
}
|
||||||
|
localStorage.setItem(sortKey, JSON.stringify(sortState));
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareGuests(a, b) {
|
||||||
|
if (sortState.key === 'name') {
|
||||||
|
const v = a.name.localeCompare(b.name, 'ja');
|
||||||
|
return v * sortState.dir;
|
||||||
|
}
|
||||||
|
if (sortState.key === 'presence') {
|
||||||
|
const av = Number(a.present);
|
||||||
|
const bv = Number(b.present);
|
||||||
|
if (av !== bv) {
|
||||||
|
return (bv - av) * sortState.dir;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, 'ja');
|
||||||
|
}
|
||||||
|
if (sortState.key === 'status') {
|
||||||
|
const av = Number(!a.present);
|
||||||
|
const bv = Number(!b.present);
|
||||||
|
if (av !== bv) {
|
||||||
|
return (av - bv) * sortState.dir;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, 'ja');
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, 'ja');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
const r = await fetch('/api/state');
|
||||||
|
const j = await r.json();
|
||||||
|
const guests = [...j.guests].sort(compareGuests);
|
||||||
|
const presentCount = guests.filter(g => g.present).length;
|
||||||
|
const rows = guests.map(g => {
|
||||||
|
const status = g.present ? '在席' : (g.absent_for || '1分未満');
|
||||||
|
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(status) + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('root').innerHTML =
|
||||||
|
'<div class="grid">' +
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>状態</h2>' +
|
||||||
|
'<p>World: ' + esc(j.world || '') + '</p>' +
|
||||||
|
'<p>Discord: <span class="' + (j.discord_muted ? 'on' : 'off') + '">' + (j.discord_muted ? 'Muted' : 'Unmuted') + '</span></p>' +
|
||||||
|
'<p>OCR: ' + esc(j.ocr || '') + '</p>' +
|
||||||
|
'<p>翻訳: ' + esc(j.translate || '未実装') + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>ゲスト (' + presentCount + '/' + j.guest_count + ')</h2>' +
|
||||||
|
'<table>' +
|
||||||
|
'<tr>' +
|
||||||
|
'<th data-sort="name">名前' + sortMark('name') + '</th>' +
|
||||||
|
'<th data-sort="presence">在席' + sortMark('presence') + '</th>' +
|
||||||
|
'</tr>' +
|
||||||
|
rows +
|
||||||
|
'</table>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.querySelectorAll('th[data-sort]').forEach(function(el) {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.onclick = function() {
|
||||||
|
setSort(el.getAttribute('data-sort'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMark(key) {
|
||||||
|
if (sortState.key !== key) return '';
|
||||||
|
return sortState.dir > 0 ? ' ▲' : ' ▼';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s){ return (s || '').replace(/[&<>"]/g, function(m){ return {'&':'&','<':'<','>':'>','"':'"'}[m]; }); }
|
||||||
|
setInterval(load, 3000);
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
_, _ = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,16 +29,19 @@ func Run(configPath string, mode string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
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)
|
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
|
||||||
discordState := &discordMuteState{muted: true}
|
discordState := &discordMuteState{muted: true}
|
||||||
|
SetDiscordMuted(true)
|
||||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||||
log.Printf("received DiscordSend args=%+v", args)
|
log.Printf("received DiscordSend args=%+v", args)
|
||||||
if err := handleDiscordSend(discordState, args); err != nil {
|
if err := handleDiscordSend(discordState, args); err != nil {
|
||||||
log.Printf("discord mute failed: %v", err)
|
log.Printf("discord mute failed: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
SetDiscordMuted(discordState.muted)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -9,13 +8,68 @@ import (
|
|||||||
|
|
||||||
func pressDiscordMuteHotkey() error {
|
func pressDiscordMuteHotkey() error {
|
||||||
script := `
|
script := `
|
||||||
$p = Get-Process | Where-Object { $_.MainWindowTitle -match 'Discord' } | Select-Object -First 1
|
$ErrorActionPreference = "Stop"
|
||||||
if ($null -eq $p) { exit 2 }
|
|
||||||
[void][System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
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
|
$wshell = New-Object -ComObject WScript.Shell
|
||||||
$null = $wshell.AppActivate($p.Id)
|
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
||||||
Start-Sleep -Milliseconds 150
|
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')
|
$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)
|
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
@@ -28,9 +82,3 @@ $wshell.SendKeys('^+m')
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pressDiscordMuteHotkeyLegacy() error {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = buf
|
|
||||||
return pressDiscordMuteHotkey()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.muted = false
|
state.muted = false
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
state.lastAction = now
|
state.lastAction = now
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already unmuted")
|
log.Printf("INFO already unmuted")
|
||||||
@@ -51,11 +52,11 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.muted = true
|
state.muted = true
|
||||||
|
SetDiscordMuted(state.muted)
|
||||||
state.lastAction = now
|
state.lastAction = now
|
||||||
} else {
|
} else {
|
||||||
log.Printf("INFO already muted")
|
log.Printf("INFO already muted")
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
136
VRWT_Tool/VRC_OSC/internal/app/guest_status.go
Normal file
136
VRWT_Tool/VRC_OSC/internal/app/guest_status.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ runOcrFromScreen()
|
|||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
log.Printf("ocr output: %s", string(out))
|
log.Printf("ocr output: %s", string(out))
|
||||||
|
SetOCRText(string(out))
|
||||||
|
SetTranslateText("翻訳: 未実装")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ocr failed: %w", err)
|
return fmt.Errorf("ocr failed: %w", err)
|
||||||
|
|||||||
89
VRWT_Tool/VRC_OSC/internal/app/state.go
Normal file
89
VRWT_Tool/VRC_OSC/internal/app/state.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -128,11 +128,19 @@ func watchVrchatLog() {
|
|||||||
if changed, worldLabel := updateWorldState(state, line); changed {
|
if changed, worldLabel := updateWorldState(state, line); changed {
|
||||||
log.Printf("world changed: %s", worldLabel)
|
log.Printf("world changed: %s", worldLabel)
|
||||||
state.presentSet = map[string]struct{}{}
|
state.presentSet = map[string]struct{}{}
|
||||||
|
SetCurrentWorld(worldLabel)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.SetCurrentInstance(worldLabel)
|
||||||
|
}
|
||||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||||
}
|
}
|
||||||
|
at := extractLineTime(line)
|
||||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkJoin(name, at)
|
||||||
|
}
|
||||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
_ = appendJoinLeaveLog(out)
|
||||||
@@ -141,6 +149,9 @@ func watchVrchatLog() {
|
|||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkLeave(name, at)
|
||||||
|
}
|
||||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||||
log.Print(out)
|
log.Print(out)
|
||||||
_ = appendJoinLeaveLog(out)
|
_ = appendJoinLeaveLog(out)
|
||||||
@@ -178,11 +189,17 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
state.presentSet[name] = struct{}{}
|
state.presentSet[name] = struct{}{}
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkJoin(name, extractLineTime(line))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||||
name := strings.TrimSpace(m[1])
|
name := strings.TrimSpace(m[1])
|
||||||
delete(state.presentSet, name)
|
delete(state.presentSet, name)
|
||||||
|
if tracker := GetGuestTracker(); tracker != nil {
|
||||||
|
tracker.MarkLeave(name, extractLineTime(line))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +209,10 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
|||||||
state.initialized = true
|
state.initialized = true
|
||||||
if state.worldName != "" {
|
if state.worldName != "" {
|
||||||
log.Printf("world changed: %s", 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)
|
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -311,3 +332,14 @@ func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type OSCConfig struct {
|
|||||||
type VrcLogConfig struct {
|
type VrcLogConfig struct {
|
||||||
SelfName string
|
SelfName string
|
||||||
GuestNames []string
|
GuestNames []string
|
||||||
|
GuestFile string
|
||||||
StaffNames []string
|
StaffNames []string
|
||||||
MissingCount int
|
MissingCount int
|
||||||
LogPatterns []string
|
LogPatterns []string
|
||||||
@@ -91,11 +92,22 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.VrcLog.StaffNames = parseList(val)
|
cfg.VrcLog.StaffNames = parseList(val)
|
||||||
}
|
}
|
||||||
case "guest":
|
case "guest":
|
||||||
if key == "names" {
|
switch key {
|
||||||
|
case "names":
|
||||||
cfg.VrcLog.GuestNames = parseList(val)
|
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()
|
return cfg, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,3 +128,19 @@ func parseList(value string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user