Compare commits
31 Commits
6add92ef40
...
v0.1.3-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd165d9301 | ||
|
|
3ff33e40e3 | ||
|
|
ae426244e0 | ||
|
|
4d77e67870 | ||
|
|
75f2d8b9c2 | ||
|
|
23d5fcfcda | ||
|
|
329bcd0c50 | ||
|
|
93a0251082 | ||
|
|
63700d9d29 | ||
|
|
ab9551c241 | ||
|
|
b6421958af | ||
|
|
8bff7af6bc | ||
|
|
760d6988cb | ||
|
|
74ff0d3c76 | ||
|
|
72aa10de46 | ||
|
|
b34c2fafb4 | ||
|
|
a4394bfde6 | ||
|
|
450316d861 | ||
|
|
e3f497def4 | ||
|
|
1975bf6e08 | ||
|
|
15a24f32ba | ||
|
|
ad81bc6985 | ||
|
|
b189adebe5 | ||
|
|
bd29099425 | ||
|
|
fad20a5677 | ||
|
|
66f94eaa9e | ||
|
|
6a2c9bb39c | ||
|
|
da8a9b3c3f | ||
|
|
6f22223394 | ||
|
|
e8eb9463fa | ||
|
|
54854969b9 |
77
.gitea/workflows/build-windows-exe.yml
Normal file
77
.gitea/workflows/build-windows-exe.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: build-windows-exe
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
run: |
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" https://git.vrcworldtour.com/every_holiday/VRCWT-OSC.git .
|
||||
|
||||
- name: Install Go
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y golang-go
|
||||
go version
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
GO111MODULE=on go test ./...
|
||||
|
||||
- name: Build executables
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
|
||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-H windowsgui -X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||
GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
|
||||
|
||||
- name: Package executables
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
(cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrc_osc_gui.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="${GITEA_BASE_URL:-https://git.vrcworldtour.com}/api/v1"
|
||||
owner_repo="every_holiday/VRCWT-OSC"
|
||||
release_json=$(curl -fsSL \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api}/repos/${owner_repo}/releases/tags/${TAG_NAME}" || true)
|
||||
if [ -n "${release_json}" ]; then
|
||||
release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
else
|
||||
release_json=$(curl -fsSL -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${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="dist/vrc_osc-windows.zip"
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${asset_path}" \
|
||||
"${api}/repos/${owner_repo}/releases/${release_id}/assets?name=vrc_osc-windows.zip"
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -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_*/
|
||||
|
||||
38
EXE.md
38
EXE.md
@@ -1,25 +1,35 @@
|
||||
# exe Packaging
|
||||
# Windows Release Packaging
|
||||
|
||||
## Build
|
||||
|
||||
Release の zip は Gitea Actions で作ります。
|
||||
|
||||
```powershell
|
||||
.\build_exe.ps1
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd\vrc_osc
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd\vrc_osc_launcher
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd\vrc_osc_gui
|
||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||
```
|
||||
|
||||
This uses `PyInstaller` and writes the result to `dist\vrc_osc\`.
|
||||
## 起動
|
||||
|
||||
## Distribution Layout
|
||||
通常は `vrc_osc_launcher.exe` を起動してください。
|
||||
|
||||
Place these next to `vrc_osc.exe`:
|
||||
- 起動時に最新 Release をチェック
|
||||
- 新しい版があれば自動更新
|
||||
- 更新後に本体を起動
|
||||
|
||||
- `config\config.toml`
|
||||
- `config\secrets.toml`
|
||||
- `config\guests.txt`
|
||||
`vrc_osc.exe` を直接起動した場合は更新チェックしません。
|
||||
|
||||
## 配置
|
||||
|
||||
Release zip を展開したフォルダに以下を置きます。
|
||||
|
||||
- `vrc_osc.exe`
|
||||
- `vrc_osc_launcher.exe`
|
||||
- `vrc_osc_gui.exe`
|
||||
- `vrwt_tool.exe`
|
||||
- `config\`
|
||||
- `runtime\`
|
||||
|
||||
`runtime\` is created on startup if it does not exist.
|
||||
|
||||
## Notes
|
||||
|
||||
- `src\common\project_paths.py` resolves paths relative to the exe when frozen.
|
||||
- Keep `config\` beside the exe. The app does not look inside the PyInstaller temp directory for persistent files.
|
||||
`runtime\` は起動時に作られます。
|
||||
|
||||
135
README.md
135
README.md
@@ -1,133 +1,74 @@
|
||||
# VRC_OSC
|
||||
|
||||
VRC_OSC は、VRChat の状態を見て Discord やログ連携を行うためのツールです。
|
||||
VRChat のログ監視、Discord ミュート連携、OCR、同意リスト生成をまとめたツールです。
|
||||
|
||||
一番の使い方は、VRChat の `ExMenu` から操作することです。
|
||||
`ExMenu` は VRChat のメニュー画面に出る、追加の操作メニューです。
|
||||
## 主な機能
|
||||
|
||||
## できること
|
||||
|
||||
- VRChat のログを見て Discord に通知する
|
||||
- 変更があった部分だけを差分で送る
|
||||
- Discord のミュートを VRChat 側の操作に合わせて切り替える
|
||||
- VRChat 画面を読み取って OCR する
|
||||
- OCR した文字を翻訳してコピーする
|
||||
|
||||
## 画面イメージ
|
||||
|
||||
### ExMenu
|
||||
|
||||
VRChat のメニュー画面から、このツールの機能を操作します。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### VRC Log
|
||||
|
||||
VRChat のログ確認や通知の流れは、次のようなイメージです。
|
||||
|
||||

|
||||
|
||||
## 使い方
|
||||
|
||||
1. 必要な設定ファイルを用意します。
|
||||
2. ツールを起動します。
|
||||
3. VRChat を開きます。
|
||||
4. VRChat の `ExMenu` から必要な機能を選びます。
|
||||
|
||||
### Discord のミュート連動
|
||||
|
||||
VRChat 側で特定の操作が行われたときに、Discord のミュート状態を切り替えます。
|
||||
通話中に VRChat を使うとき、Discord の音声操作を手で切り替える手間を減らすための機能です。
|
||||
|
||||
### OCR と翻訳
|
||||
|
||||
VRChat 画面の文字を読み取って、必要に応じて翻訳します。
|
||||
見た文字や翻訳結果はコピーできるので、内容をそのまま使いやすくなります。
|
||||
- VRChat の入退出ログを監視して `join / leave` を出力
|
||||
- Discord のミュート切り替え
|
||||
- OCR で画面文字を取得
|
||||
- 同意フォーム用の JSON 生成とログ抽出
|
||||
- Gitea Release からの自動更新
|
||||
|
||||
## 起動方法
|
||||
|
||||
PowerShell から起動する場合:
|
||||
配布 zip を展開したら、まず `vrc_osc_launcher.exe` を起動してください。
|
||||
|
||||
```powershell
|
||||
bin\vrc_osc.ps1
|
||||
vrc_osc_launcher.exe
|
||||
```
|
||||
|
||||
Python から直接起動する場合:
|
||||
launcher は起動時に最新 Release を確認します。新しい版があれば自動で更新してから本体を起動します。
|
||||
|
||||
本体を直接起動することもできます。
|
||||
|
||||
```powershell
|
||||
python src\app.py
|
||||
vrc_osc.exe
|
||||
```
|
||||
|
||||
## 事前準備
|
||||
ただし、この場合は自動更新は走りません。
|
||||
|
||||
依存関係を入れます。
|
||||
## 配布物
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
Release の zip には次のファイルが入ります。
|
||||
|
||||
## 設定ファイル
|
||||
- `vrc_osc.exe`
|
||||
- `vrc_osc_launcher.exe`
|
||||
- `vrwt_tool.exe`
|
||||
- `config.example.toml`
|
||||
- `secrets.example.toml`
|
||||
- `guests.example.txt`
|
||||
|
||||
設定は `config/` に置きます。
|
||||
## 設定
|
||||
|
||||
設定ファイルは exe の横の `config/` に置きます。
|
||||
|
||||
- `config/config.toml`
|
||||
- `config/secrets.toml`
|
||||
- `config/guests.txt`
|
||||
|
||||
サンプルはそれぞれ `*.example.*` を使って作成できます。
|
||||
最初は各 `*.example.*` をコピーして使ってください。
|
||||
|
||||
## 初期セットアップ
|
||||
## Release 更新
|
||||
|
||||
最初は、`config/` のサンプルファイルを実ファイルにしてから中身を埋めます。
|
||||
自動更新は Gitea の Release を参照します。
|
||||
|
||||
1. `config/config.example.toml` を `config/config.toml` にコピーします。
|
||||
2. `config/secrets.example.toml` を `config/secrets.toml` にコピーします。
|
||||
3. `config/guests.example.txt` を `config/guests.txt` にコピーします。
|
||||
4. `config/config.toml` の `self.name` を自分の VRChat 表示名にします。
|
||||
5. `config/secrets.toml` の `discord.webhook_url` を設定します。
|
||||
6. `config/secrets.toml` の `vrchat.username` と `vrchat.password` を設定します。
|
||||
7. `config/guests.txt` に、あらかじめ guest として見たい参加者名を 1 行ずつ書きます。
|
||||
8. 自分の名前も guest 扱いしたい場合は、`guests.txt` に自分の名前も 1 行追加します。
|
||||
- 更新元: `https://git.vrcworldtour.com/every_holiday/VRCWT-OSC/releases`
|
||||
- 対象 asset: `vrc_osc-windows.zip`
|
||||
|
||||
`guests.txt` は、例として次のように書きます。
|
||||
## 開発時のビルド
|
||||
|
||||
```text
|
||||
GuestUserA
|
||||
GuestUserB
|
||||
YourNameHere
|
||||
```powershell
|
||||
go test ./...
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc.exe ./cmd/vrc_osc
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
|
||||
go build -ldflags="-H windowsgui" -o .\dist\vrc_osc_gui.exe ./cmd/vrc_osc_gui
|
||||
go build -o .\dist\vrwt_tool.exe ./cmd\vrwt_tool
|
||||
```
|
||||
|
||||
## よく使う設定
|
||||
|
||||
- `discord.webhook_url`
|
||||
- Discord に通知を送るための Webhook URL
|
||||
- `vrchat.username`
|
||||
- VRChat のアカウント名
|
||||
- `vrchat.password`
|
||||
- VRChat のパスワード
|
||||
- `staff.names`
|
||||
- スタッフとして扱う名前
|
||||
- `guest.file`
|
||||
- guest 名簿のファイル
|
||||
|
||||
## 補足
|
||||
|
||||
この README は、まず使い方が分かることを優先して簡単にまとめています。
|
||||
細かい内部仕様が必要な場合は、ソースコードと `config/*.example.*` を見てください。
|
||||
|
||||
## 生成されるログ
|
||||
## ログ
|
||||
|
||||
- `runtime/runtime.log`
|
||||
- 通常の実行ログ
|
||||
- `runtime/join_leave.log`
|
||||
- join / leave だけを抜き出したログ
|
||||
- `runtime/latest_ocr.png`
|
||||
- `runtime/latest_screenshot.png`
|
||||
|
||||
## 詳しい資料
|
||||
|
||||
- [設計書](doc/design.md)
|
||||
- [シーケンス図](doc/sequence.md)
|
||||
- [ワークフロー](doc/workflow.md)
|
||||
|
||||
BIN
assets/icon.ico
Normal file
BIN
assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -4,18 +4,52 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Printf("vrc_osc version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
|
||||
log.Printf("runtime main begin")
|
||||
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 err := app.Run(configPath); err != nil {
|
||||
if mode == "" && !noGUI {
|
||||
log.Printf("launching gui companion")
|
||||
launchGUI()
|
||||
}
|
||||
|
||||
log.Printf("calling app.Run config=%s mode=%s noGUI=%v", configPath, mode, noGUI)
|
||||
if err := app.Run(configPath, mode); err != nil {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.Fatalf("vrc_osc_go: %v", err)
|
||||
}
|
||||
log.Printf("app.Run returned cleanly")
|
||||
}
|
||||
|
||||
func launchGUI() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("launchGUI: os.Executable failed: %v", err)
|
||||
return
|
||||
}
|
||||
gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
|
||||
log.Printf("launchGUI: exe=%s gui=%s", exe, gui)
|
||||
if _, err := os.Stat(gui); err != nil {
|
||||
log.Printf("launchGUI: gui missing: %v", err)
|
||||
return
|
||||
}
|
||||
if err := exec.Command(gui).Start(); err != nil {
|
||||
log.Printf("launchGUI: start failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("launchGUI: started gui companion")
|
||||
}
|
||||
|
||||
1160
cmd/vrc_osc_gui/history_view_windows.go
Normal file
1160
cmd/vrc_osc_gui/history_view_windows.go
Normal file
File diff suppressed because it is too large
Load Diff
97
cmd/vrc_osc_gui/main.go
Normal file
97
cmd/vrc_osc_gui/main.go
Normal file
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"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)+" initial_tab="+initialTab)
|
||||
} else {
|
||||
_ = app.AppendRuntimeLog("GUI", "starting vrc_osc_gui.exe exe=unknown err="+err.Error())
|
||||
}
|
||||
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)
|
||||
}
|
||||
5
cmd/vrc_osc_gui/main_nonwindows.go
Normal file
5
cmd/vrc_osc_gui/main_nonwindows.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
BIN
cmd/vrc_osc_gui/rsrc_windows_amd64.syso
Normal file
BIN
cmd/vrc_osc_gui/rsrc_windows_amd64.syso
Normal file
Binary file not shown.
232
cmd/vrc_osc_gui/tray_windows.go
Normal file
232
cmd/vrc_osc_gui/tray_windows.go
Normal file
@@ -0,0 +1,232 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
wmUser = 0x0400
|
||||
wmTray = wmUser + 1
|
||||
wmCommand = 0x0111
|
||||
wmDestroy = 0x0002
|
||||
wmClose = 0x0010
|
||||
wmRButtonUp = 0x0205
|
||||
wmLButtonUp = 0x0202
|
||||
wmCreate = 0x0001
|
||||
wmQuit = 0x0012
|
||||
niAdd = 0x00000000
|
||||
niModify = 0x00000001
|
||||
niDelete = 0x00000002
|
||||
nifMessage = 0x00000001
|
||||
nifIcon = 0x00000002
|
||||
nifTip = 0x00000004
|
||||
nifInfo = 0x00000010
|
||||
nimPopup = 0x00000000
|
||||
miString = 0x00000000
|
||||
miSeparator = 0x00000800
|
||||
miDefault = 0x00001000
|
||||
swHide = 0
|
||||
idOpen = 1001
|
||||
idExit = 1002
|
||||
maxTip = 128
|
||||
maxClassName = 64
|
||||
)
|
||||
|
||||
type trayApp struct {
|
||||
uiURL string
|
||||
open func()
|
||||
}
|
||||
|
||||
func newTray(uiURL string, open func()) *trayApp {
|
||||
return &trayApp{uiURL: uiURL, open: open}
|
||||
}
|
||||
|
||||
func (t *trayApp) Run() error {
|
||||
return runTray(t.uiURL, t.open)
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
}
|
||||
|
||||
func trayIconPath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return filepath.Join("doc", "vrcworldtour_new.ico")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "..", "doc", "vrcworldtour_new.ico")
|
||||
}
|
||||
|
||||
func runTray(uiURL string, open func()) error {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
registerClass := user32.NewProc("RegisterClassW")
|
||||
createWindowEx := user32.NewProc("CreateWindowExW")
|
||||
defWindowProc := user32.NewProc("DefWindowProcW")
|
||||
getMessage := user32.NewProc("GetMessageW")
|
||||
translateMessage := user32.NewProc("TranslateMessage")
|
||||
dispatchMessage := user32.NewProc("DispatchMessageW")
|
||||
postQuitMessage := user32.NewProc("PostQuitMessage")
|
||||
createPopupMenu := user32.NewProc("CreatePopupMenu")
|
||||
appendMenu := user32.NewProc("AppendMenuW")
|
||||
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||
trackPopupMenu := user32.NewProc("TrackPopupMenu")
|
||||
showWindow := user32.NewProc("ShowWindow")
|
||||
loadImage := user32.NewProc("LoadImageW")
|
||||
shellNotifyIcon := shell32.NewProc("Shell_NotifyIconW")
|
||||
getModuleHandle := kernel32.NewProc("GetModuleHandleW")
|
||||
|
||||
type wndClassEx struct {
|
||||
cbSize uint32
|
||||
style uint32
|
||||
lpfnWndProc uintptr
|
||||
cbClsExtra int32
|
||||
cbWndExtra int32
|
||||
hInstance uintptr
|
||||
hIcon uintptr
|
||||
hCursor uintptr
|
||||
hbrBackground uintptr
|
||||
lpszMenuName *uint16
|
||||
lpszClassName *uint16
|
||||
hIconSm uintptr
|
||||
}
|
||||
type point struct{ X, Y int32 }
|
||||
type msg struct {
|
||||
hwnd uintptr
|
||||
message uint32
|
||||
wParam uintptr
|
||||
lParam uintptr
|
||||
time uint32
|
||||
pt point
|
||||
}
|
||||
type notifyIconData struct {
|
||||
cbSize uint32
|
||||
hWnd uintptr
|
||||
uID uint32
|
||||
uFlags uint32
|
||||
uCallbackMessage uint32
|
||||
hIcon uintptr
|
||||
szTip [maxTip]uint16
|
||||
dwState uint32
|
||||
dwStateMask uint32
|
||||
szInfo [256]uint16
|
||||
uTimeoutVersion uint32
|
||||
szInfoTitle [64]uint16
|
||||
dwInfoFlags uint32
|
||||
guidItem [16]byte
|
||||
hBalloonIcon uintptr
|
||||
}
|
||||
|
||||
className, _ := syscall.UTF16PtrFromString("VRC_OSC_TRAY")
|
||||
windowTitle, _ := syscall.UTF16PtrFromString("VRC OSC Tray")
|
||||
iconPath := trayIconPath()
|
||||
iconPathPtr, _ := syscall.UTF16PtrFromString(iconPath)
|
||||
const (
|
||||
lrLoadFromFile = 0x00000010
|
||||
imageIcon = 1
|
||||
)
|
||||
icon, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(iconPathPtr)), imageIcon, 0, 0, lrLoadFromFile)
|
||||
|
||||
var hwnd uintptr
|
||||
var menu uintptr
|
||||
var nid notifyIconData
|
||||
|
||||
wndProc := syscall.NewCallback(func(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
|
||||
switch msg {
|
||||
case wmCreate:
|
||||
return 0
|
||||
case wmTray:
|
||||
switch lParam {
|
||||
case wmLButtonUp:
|
||||
if open != nil {
|
||||
open()
|
||||
}
|
||||
case wmRButtonUp:
|
||||
if menu == 0 {
|
||||
m, _, _ := createPopupMenu.Call()
|
||||
menu = m
|
||||
openText, _ := syscall.UTF16PtrFromString("Open UI")
|
||||
exitText, _ := syscall.UTF16PtrFromString("Exit")
|
||||
appendMenu.Call(menu, miString|miDefault, idOpen, uintptr(unsafe.Pointer(openText)))
|
||||
appendMenu.Call(menu, miString, idExit, uintptr(unsafe.Pointer(exitText)))
|
||||
}
|
||||
setForegroundWindow.Call(hwnd)
|
||||
var p point
|
||||
user32.NewProc("GetCursorPos").Call(uintptr(unsafe.Pointer(&p)))
|
||||
trackPopupMenu.Call(menu, nimPopup, uintptr(p.X), uintptr(p.Y), 0, hwnd, 0)
|
||||
}
|
||||
return 0
|
||||
case wmCommand:
|
||||
switch uint16(wParam & 0xffff) {
|
||||
case idOpen:
|
||||
if open != nil {
|
||||
open()
|
||||
}
|
||||
case idExit:
|
||||
user32.NewProc("DestroyWindow").Call(hwnd)
|
||||
}
|
||||
return 0
|
||||
case wmDestroy:
|
||||
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = 1
|
||||
shellNotifyIcon.Call(niDelete, uintptr(unsafe.Pointer(&nid)))
|
||||
postQuitMessage.Call(0)
|
||||
return 0
|
||||
}
|
||||
ret, _, _ := defWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
|
||||
return ret
|
||||
})
|
||||
|
||||
hInstance, _, _ := getModuleHandle.Call(0)
|
||||
_, _, regErr := registerClass.Call(uintptr(unsafe.Pointer(&wndClassEx{
|
||||
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||
lpfnWndProc: wndProc,
|
||||
hInstance: hInstance,
|
||||
hIcon: icon,
|
||||
lpszClassName: className,
|
||||
})))
|
||||
if regErr != nil {
|
||||
return fmt.Errorf("RegisterClassW failed: %v", regErr)
|
||||
}
|
||||
|
||||
hwnd, _, err := createWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowTitle)), 0, 0, 0, 0, 0, 0, 0, hInstance, 0)
|
||||
if hwnd == 0 {
|
||||
return fmt.Errorf("CreateWindowExW failed: %v", err)
|
||||
}
|
||||
showWindow.Call(hwnd, swHide)
|
||||
|
||||
nid.cbSize = uint32(unsafe.Sizeof(nid))
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = 1
|
||||
nid.uFlags = nifMessage | nifIcon | nifTip
|
||||
nid.uCallbackMessage = wmTray
|
||||
nid.hIcon = icon
|
||||
tip := syscall.StringToUTF16("VRC OSC")
|
||||
copy(nid.szTip[:], tip)
|
||||
if ok, _, _ := shellNotifyIcon.Call(niAdd, uintptr(unsafe.Pointer(&nid))); ok == 0 {
|
||||
return fmt.Errorf("Shell_NotifyIconW add failed")
|
||||
}
|
||||
log.Printf("tray started")
|
||||
|
||||
var m msg
|
||||
for {
|
||||
r, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||
if int32(r) <= 0 {
|
||||
break
|
||||
}
|
||||
translateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||
dispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
4521
cmd/vrc_osc_gui/window_windows.go
Normal file
4521
cmd/vrc_osc_gui/window_windows.go
Normal file
File diff suppressed because it is too large
Load Diff
6
cmd/vrc_osc_launcher/launcher_icon.go
Normal file
6
cmd/vrc_osc_launcher/launcher_icon.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// This file exists so the launcher package has a Windows build target
|
||||
// alongside the generated .syso resource file.
|
||||
94
cmd/vrc_osc_launcher/main.go
Normal file
94
cmd/vrc_osc_launcher/main.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"vrc_osc_go/internal/app"
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/common"
|
||||
"vrc_osc_go/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = appendLauncherLog("LAUNCHER", "launcher main begin")
|
||||
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)
|
||||
_ = appendLauncherLog("LAUNCHER", "auto update skipped: "+err.Error())
|
||||
} else {
|
||||
_ = appendLauncherLog("LAUNCHER", "auto update ok")
|
||||
}
|
||||
base := runtimeBaseDir()
|
||||
exe := filepath.Join(base, "vrc_osc.exe")
|
||||
gui := filepath.Join(base, "vrc_osc_gui.exe")
|
||||
_ = appendLauncherLog("LAUNCHER", "runtimeBaseDir="+base)
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
_ = appendLauncherLog("LAUNCHER", "missing runtime exe: "+err.Error())
|
||||
showError("vrc_osc_launcher", "missing exe: "+err.Error())
|
||||
log.Fatalf("missing exe: %v", err)
|
||||
}
|
||||
_ = appendLauncherLog("LAUNCHER", "version="+buildinfo.Version+" build="+buildinfo.BuildTime+" base="+base+" exe="+exe+" gui="+gui)
|
||||
if _, err := os.Stat(gui); err == nil {
|
||||
_ = appendLauncherLog("LAUNCHER", "starting gui companion")
|
||||
_ = exec.Command(gui).Start()
|
||||
} else {
|
||||
_ = appendLauncherLog("LAUNCHER", "gui companion missing: "+err.Error())
|
||||
}
|
||||
args := append([]string{"--no-gui"}, os.Args[1:]...)
|
||||
_ = appendLauncherLog("LAUNCHER", "launching runtime args="+strings.Join(args, " "))
|
||||
if err := update.Launch(exe, args); err != nil {
|
||||
_ = appendLauncherLog("LAUNCHER", "launch failed: "+err.Error())
|
||||
showError("vrc_osc_launcher", "launch failed: "+err.Error())
|
||||
log.Fatalf("launch failed: %v", err)
|
||||
}
|
||||
_ = appendLauncherLog("LAUNCHER", "runtime launch returned")
|
||||
}
|
||||
|
||||
func showError(title, message string) {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
msgBox := user32.NewProc("MessageBoxW")
|
||||
const mbOK = 0x00000000
|
||||
const mbIconError = 0x00000010
|
||||
_, _, _ = msgBox.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
|
||||
uintptr(mbOK|mbIconError),
|
||||
)
|
||||
}
|
||||
|
||||
func runtimeBaseDir() string {
|
||||
root := common.RootDir()
|
||||
if _, err := os.Stat(filepath.Join(root, "vrc_osc.exe")); err == nil {
|
||||
return root
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "tmp", "vrc_osc.exe")); err == nil {
|
||||
return filepath.Join(root, "tmp")
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func appendLauncherLog(title, text string) error {
|
||||
return app.AppendRuntimeLog(title, text)
|
||||
}
|
||||
BIN
cmd/vrc_osc_launcher/rsrc_windows_amd64.syso
Normal file
BIN
cmd/vrc_osc_launcher/rsrc_windows_amd64.syso
Normal file
Binary file not shown.
33
cmd/vrwt_tool/main.go
Normal file
33
cmd/vrwt_tool/main.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,19 @@ names = []
|
||||
[guest]
|
||||
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
|
||||
|
||||
|
||||
35
config/config.test.toml
Normal file
35
config/config.test.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[self]
|
||||
name = "毎日がHoliday'"
|
||||
|
||||
[staff]
|
||||
file = "config/staff.txt"
|
||||
[guest]
|
||||
file = "config/guests.txt"
|
||||
|
||||
[gui]
|
||||
top_most = false
|
||||
font_size = 18
|
||||
|
||||
[notice]
|
||||
missing_count = 0
|
||||
|
||||
[vrc_log]
|
||||
patterns = [
|
||||
"OnPlayerJoined",
|
||||
"OnPlayerLeft",
|
||||
"Entering Room",
|
||||
"Joining or Creating Room",
|
||||
"worldId=",
|
||||
"wrld_",
|
||||
]
|
||||
|
||||
[ocr.crop]
|
||||
left = 0.05
|
||||
top = 0.35
|
||||
right = 0.95
|
||||
bottom = 0.95
|
||||
|
||||
[ocr]
|
||||
preprocess = true
|
||||
scale = 1.5
|
||||
use_angle_cls = false
|
||||
BIN
doc/vrcworldtour.png
Normal file
BIN
doc/vrcworldtour.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 732 KiB |
BIN
doc/vrcworldtour_new.ico
Normal file
BIN
doc/vrcworldtour_new.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
doc/vrcworldtour_new.png
Normal file
BIN
doc/vrcworldtour_new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
124
doc/workflow.md
124
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 に保存]
|
||||
```
|
||||
|
||||
@@ -1,37 +1,100 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
func Run(configPath string) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
type discordMuteState struct {
|
||||
mu sync.Mutex
|
||||
muted bool
|
||||
buttonPressed bool
|
||||
lastAction time.Time
|
||||
lastPressEdge time.Time
|
||||
}
|
||||
|
||||
func Run(configPath string, mode string) error {
|
||||
if mode != "" {
|
||||
return consenttool.Run(configPath, mode)
|
||||
}
|
||||
log.Printf("app run begin config=%s mode=%s", configPath, mode)
|
||||
if err := setupRuntimeLogger(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("runtime logger ready")
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
log.Printf("config load failed: %v", err)
|
||||
return err
|
||||
}
|
||||
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)
|
||||
discordState := &discordMuteState{muted: true}
|
||||
SetDiscordMuted(true)
|
||||
server.Map("DiscordSend", func(_ string, args []osc.Value) error {
|
||||
return fmt.Errorf("discord mute not implemented yet")
|
||||
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
|
||||
})
|
||||
server.Map("translation", func(_ string, args []osc.Value) error {
|
||||
log.Printf("received translation args=%+v", args)
|
||||
if err := runPythonTranslationFromLastOCR(); err != nil {
|
||||
log.Printf("translation failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- server.Serve() }()
|
||||
go func() {
|
||||
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
|
||||
errCh <- server.Serve()
|
||||
}()
|
||||
log.Printf("starting vrchat log watcher")
|
||||
go watchVrchatLog()
|
||||
log.Printf("vrchat log watcher goroutine started")
|
||||
startSelfMonitor(cfg, discordState)
|
||||
log.Printf("self monitor started")
|
||||
|
||||
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")
|
||||
if tracker := GetWorldHistoryTracker(); tracker != nil {
|
||||
tracker.FinalizeCurrent(time.Now())
|
||||
}
|
||||
server.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,43 +10,6 @@ func pressDiscordMuteHotkey() error {
|
||||
script := `
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class Win32 {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
||||
}
|
||||
"@
|
||||
|
||||
function Click-WindowCenter($p) {
|
||||
if ($null -eq $p) { return }
|
||||
$hWnd = [IntPtr]$p.MainWindowHandle
|
||||
if ($hWnd -eq [IntPtr]::Zero) { return }
|
||||
|
||||
$rect = New-Object 'Win32+RECT'
|
||||
if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return }
|
||||
|
||||
$x = [int](($rect.Left + $rect.Right) / 2)
|
||||
$y = [int](($rect.Top + $rect.Bottom) / 2)
|
||||
[Win32]::SetCursorPos($x, $y) | Out-Null
|
||||
Start-Sleep -Milliseconds 50
|
||||
[Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||
[Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
|
||||
function Get-DiscordWindows {
|
||||
$windows = foreach ($p in Get-Process) {
|
||||
$title = ($p.MainWindowTitle | Out-String).Trim()
|
||||
@@ -92,7 +55,6 @@ function Activate-Window($p) {
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
||||
Start-Sleep -Milliseconds 200
|
||||
Click-WindowCenter $p
|
||||
}
|
||||
|
||||
$discord = Get-BestDiscordWindow
|
||||
|
||||
116
internal/app/discord_mute.go
Normal file
116
internal/app/discord_mute.go
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
log.Printf("discord hotkey failed: %v", err)
|
||||
SetDiscordAction("hotkey_failed")
|
||||
} else {
|
||||
state.muted = !state.muted
|
||||
SetDiscordMuted(state.muted)
|
||||
if state.muted {
|
||||
SetDiscordAction("muted")
|
||||
} else {
|
||||
SetDiscordAction("unmuted")
|
||||
}
|
||||
}
|
||||
state.lastAction = now
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isPressed && state.buttonPressed {
|
||||
state.buttonPressed = false
|
||||
}
|
||||
|
||||
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':
|
||||
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)
|
||||
}
|
||||
}
|
||||
144
internal/app/guest_status.go
Normal file
144
internal/app/guest_status.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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) 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()
|
||||
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)
|
||||
}
|
||||
58
internal/app/guest_status_test.go
Normal file
58
internal/app/guest_status_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
func TestGuestTrackerJoinLeave(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
prev := common.SetRootDirForTest(func() string { return base })
|
||||
defer prev()
|
||||
|
||||
tracker := NewGuestTracker([]string{"Alice", "Bob"})
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.Local)
|
||||
|
||||
tracker.MarkJoin("Alice", now)
|
||||
tracker.MarkJoin("Bob", now.Add(10*time.Second))
|
||||
|
||||
got := tracker.Snapshot(now.Add(20 * time.Second))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 guests, got %d", len(got))
|
||||
}
|
||||
|
||||
present := 0
|
||||
for _, g := range got {
|
||||
if g.Present {
|
||||
present++
|
||||
}
|
||||
}
|
||||
if present != 2 {
|
||||
t.Fatalf("expected 2 present guests, got %#v", got)
|
||||
}
|
||||
|
||||
tracker.MarkLeave("Alice", now.Add(30*time.Second))
|
||||
got = tracker.Snapshot(now.Add(40 * time.Second))
|
||||
|
||||
var alice GuestStatus
|
||||
for _, g := range got {
|
||||
if g.Name == "Alice" {
|
||||
alice = g
|
||||
}
|
||||
}
|
||||
if alice.Present {
|
||||
t.Fatalf("expected Alice to be absent, got %#v", alice)
|
||||
}
|
||||
if alice.AbsentFor == "" {
|
||||
t.Fatalf("expected Alice absent duration, got %#v", alice)
|
||||
}
|
||||
|
||||
snapshotPath := filepath.Join(base, "runtime", "guest_snapshot.json")
|
||||
if _, err := os.Stat(snapshotPath); err != nil {
|
||||
t.Fatalf("expected snapshot file: %v", err)
|
||||
}
|
||||
}
|
||||
214
internal/app/join_leave_events.go
Normal file
214
internal/app/join_leave_events.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
const joinLeaveEventsFileName = "join_leave_events.json"
|
||||
|
||||
var joinLeaveEventLinePattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
|
||||
|
||||
type JoinLeaveEvent struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type joinLeaveEventsSnapshot struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Events []JoinLeaveEvent `json:"events"`
|
||||
}
|
||||
|
||||
type JoinLeaveEventTracker struct {
|
||||
mu sync.RWMutex
|
||||
events []JoinLeaveEvent
|
||||
}
|
||||
|
||||
var joinLeaveEventTracker *JoinLeaveEventTracker
|
||||
|
||||
func InitJoinLeaveEventTracker(t *JoinLeaveEventTracker) {
|
||||
joinLeaveEventTracker = t
|
||||
}
|
||||
|
||||
func GetJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
||||
return joinLeaveEventTracker
|
||||
}
|
||||
|
||||
func NewJoinLeaveEventTracker() *JoinLeaveEventTracker {
|
||||
t := &JoinLeaveEventTracker{}
|
||||
_ = t.load()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) load() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
path := filepath.Join(dir, joinLeaveEventsFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil && len(b) > 0 {
|
||||
if events, ok := decodeJoinLeaveEventsSnapshot(b); ok {
|
||||
t.events = dedupeJoinLeaveEvents(events)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
events, err := loadJoinLeaveEventsFromTextLog(filepath.Join(dir, "join_leave.log"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.events = dedupeJoinLeaveEvents(events)
|
||||
if len(t.events) > 0 {
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJoinLeaveEventsSnapshot(b []byte) ([]JoinLeaveEvent, bool) {
|
||||
var snap joinLeaveEventsSnapshot
|
||||
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
|
||||
return snap.Events, true
|
||||
}
|
||||
var events []JoinLeaveEvent
|
||||
if err := json.Unmarshal(b, &events); err == nil {
|
||||
return events, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func loadJoinLeaveEventsFromTextLog(path string) ([]JoinLeaveEvent, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]JoinLeaveEvent, 0, 256)
|
||||
var currentAt time.Time
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "VRC JOIN/LEAVE") {
|
||||
if len(line) >= 21 {
|
||||
if at, err := time.Parse("2006-01-02 15:04:05", line[1:20]); err == nil {
|
||||
currentAt = at
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := joinLeaveEventLinePattern.FindStringSubmatch(line)
|
||||
if len(m) != 4 {
|
||||
continue
|
||||
}
|
||||
count, _ := strconv.Atoi(m[3])
|
||||
events = append(events, JoinLeaveEvent{
|
||||
At: currentAt,
|
||||
Kind: m[1],
|
||||
Name: strings.TrimSpace(m[2]),
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) Record(kind, name string, count int, at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
kind = strings.TrimSpace(kind)
|
||||
name = strings.TrimSpace(name)
|
||||
if kind != "join" && kind != "leave" {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
ev := JoinLeaveEvent{At: at, Kind: kind, Name: name, Count: count}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if containsJoinLeaveEvent(t.events, ev) {
|
||||
return
|
||||
}
|
||||
t.events = append(t.events, ev)
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) Snapshot() []JoinLeaveEvent {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := append([]JoinLeaveEvent(nil), t.events...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].At.Equal(out[j].At) {
|
||||
if out[i].Kind == out[j].Kind {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
return out[i].At.Before(out[j].At)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *JoinLeaveEventTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, joinLeaveEventsFileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(joinLeaveEventsSnapshot{
|
||||
UpdatedAt: time.Now(),
|
||||
Events: append([]JoinLeaveEvent(nil), t.events...),
|
||||
})
|
||||
}
|
||||
|
||||
func dedupeJoinLeaveEvents(events []JoinLeaveEvent) []JoinLeaveEvent {
|
||||
if len(events) <= 1 {
|
||||
return events
|
||||
}
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
out := make([]JoinLeaveEvent, 0, len(events))
|
||||
for _, ev := range events {
|
||||
key := joinLeaveEventKey(ev)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsJoinLeaveEvent(events []JoinLeaveEvent, candidate JoinLeaveEvent) bool {
|
||||
key := joinLeaveEventKey(candidate)
|
||||
for _, ev := range events {
|
||||
if joinLeaveEventKey(ev) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func joinLeaveEventKey(ev JoinLeaveEvent) string {
|
||||
return ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + ev.Name
|
||||
}
|
||||
26
internal/app/join_leave_events_test.go
Normal file
26
internal/app/join_leave_events_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeduplicateJoinLeaveEventsIgnoresRepeatedReplays(t *testing.T) {
|
||||
at := time.Date(2026, 7, 1, 1, 23, 45, 0, time.FixedZone("JST", 9*60*60))
|
||||
events := []JoinLeaveEvent{
|
||||
{At: at, Kind: "join", Name: "Alice", Count: 10},
|
||||
{At: at, Kind: "join", Name: "Alice", Count: 11},
|
||||
{At: at.Add(5 * time.Second), Kind: "leave", Name: "Alice", Count: 9},
|
||||
}
|
||||
|
||||
got := dedupeJoinLeaveEvents(events)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 unique events, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Count != 10 {
|
||||
t.Fatalf("expected first event to remain stable, got %#v", got[0])
|
||||
}
|
||||
if got[1].Kind != "leave" {
|
||||
t.Fatalf("expected leave event to remain, got %#v", got[1])
|
||||
}
|
||||
}
|
||||
128
internal/app/ocr.go
Normal file
128
internal/app/ocr.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type OcrResult struct {
|
||||
ImagePath string
|
||||
TextPath string
|
||||
Text string
|
||||
Translate string
|
||||
ErrReason string
|
||||
}
|
||||
|
||||
func runPythonScript(script string) (string, error) {
|
||||
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("python output: %s", string(out))
|
||||
}
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("python failed: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func capturePythonOcr() (map[string]any, error) {
|
||||
script := `
|
||||
import sys, json
|
||||
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
|
||||
result = runOcrFromScreen()
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
`
|
||||
out, err := runPythonScript(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("ocr result json not found")
|
||||
}
|
||||
|
||||
func runPythonOcrFromScreen() error {
|
||||
log.Printf("ocr trigger begin")
|
||||
result, err := capturePythonOcr()
|
||||
if err != nil {
|
||||
log.Printf("ocr trigger failed: %v", err)
|
||||
SetOCRText("OCRテキストなし")
|
||||
SetTranslateText("翻訳エンジン未設定")
|
||||
_ = AppendRuntimeLog("OCR ERROR", err.Error())
|
||||
return err
|
||||
}
|
||||
text, _ := result["text"].(string)
|
||||
imagePath, _ := result["image_path"].(string)
|
||||
textPath, _ := result["text_path"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
text = "OCRテキストなし"
|
||||
}
|
||||
SetOCRText(text)
|
||||
SetTranslateText("")
|
||||
_ = AppendRuntimeLog("OCR INPUT", fmt.Sprintf("image=%s\n%s", imagePath, text))
|
||||
if textPath != "" {
|
||||
_ = AppendRuntimeLog("OCR TEXT", fmt.Sprintf("image=%s\ntext_path=%s\n%s", imagePath, textPath, text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPythonTranslationFromLastOCR() error {
|
||||
runtime := SnapshotRuntime()
|
||||
source := strings.TrimSpace(runtime.LastOCRText)
|
||||
if source == "" || source == "OCRテキストなし" {
|
||||
SetTranslateText("OCRテキストなし")
|
||||
_ = AppendRuntimeLog("TRANSLATION ERROR", "OCRテキストなし")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("translation trigger begin")
|
||||
SetTranslateText("翻訳実行中")
|
||||
_ = AppendRuntimeLog("TRANSLATION INPUT", source)
|
||||
script := `
|
||||
import sys, json
|
||||
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 translate.translate_actions import translateTextToJapanese, saveTranslationText
|
||||
text = sys.argv[1]
|
||||
translated = translateTextToJapanese(text)
|
||||
print(translated or "")
|
||||
if translated:
|
||||
saveTranslationText("runtime", translated)
|
||||
`
|
||||
cmd := exec.Command("python", "-c", script, source)
|
||||
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
log.Printf("translation output: %s", string(out))
|
||||
}
|
||||
if err != nil {
|
||||
SetTranslateText("翻訳失敗")
|
||||
_ = AppendRuntimeLog("TRANSLATION ERROR", err.Error())
|
||||
return err
|
||||
}
|
||||
translated := strings.TrimSpace(string(out))
|
||||
if translated == "" {
|
||||
SetTranslateText("翻訳結果なし")
|
||||
_ = AppendRuntimeLog("TRANSLATION ERROR", "翻訳結果なし")
|
||||
return nil
|
||||
}
|
||||
SetTranslateText(translated)
|
||||
_ = AppendRuntimeLog("TRANSLATION RESULT", translated)
|
||||
return nil
|
||||
}
|
||||
28
internal/app/refresh_interval.go
Normal file
28
internal/app/refresh_interval.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
)
|
||||
|
||||
const defaultGUIRefreshInterval = 2 * time.Second
|
||||
|
||||
func guiRefreshIntervalFromConfig(cfg *config.Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return defaultGUIRefreshInterval
|
||||
}
|
||||
interval := cfg.GUI.RefreshInterval()
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func currentGUIRefreshInterval() time.Duration {
|
||||
cfg, err := config.Load("")
|
||||
if err != nil || cfg == nil {
|
||||
return defaultGUIRefreshInterval
|
||||
}
|
||||
return guiRefreshIntervalFromConfig(cfg)
|
||||
}
|
||||
153
internal/app/runtime.go
Normal file
153
internal/app/runtime.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type runtimeLogWriter struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (w *runtimeLogWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
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")
|
||||
if err := runtimeLogFileWriter.ensureFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, &runtimeLogFileWriter))
|
||||
return nil
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
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 {
|
||||
return appendRuntimeLog(title, text)
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
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 {
|
||||
dir := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if home, herr := os.UserHomeDir(); herr == nil {
|
||||
dir = filepath.Join(home, "Desktop")
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
dir = filepath.Join(common.RootDir(), "runtime")
|
||||
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path := filepath.Join(dir, "join.txt")
|
||||
body := text
|
||||
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")
|
||||
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
|
||||
return err
|
||||
}
|
||||
fallbackPath := filepath.Join(fallbackDir, "join.txt")
|
||||
f, err = os.OpenFile(fallbackPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = fallbackPath
|
||||
}
|
||||
defer f.Close()
|
||||
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 {
|
||||
return appendDesktopJoinLog(title, worldLabel, text)
|
||||
}
|
||||
71
internal/app/self_monitor.go
Normal file
71
internal/app/self_monitor.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
)
|
||||
|
||||
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(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
text, err := readFileTail(path, vrchatLogTailBytes)
|
||||
if err != nil {
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
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 == "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" {
|
||||
log.Printf("self monitor detected leave; auto unmute disabled")
|
||||
}
|
||||
SetDiscordSource("self_monitor")
|
||||
lastState = nextState
|
||||
}
|
||||
time.Sleep(guiRefreshIntervalFromConfig(liveCfg))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
141
internal/app/state.go
Normal file
141
internal/app/state.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type RuntimeState struct {
|
||||
mu sync.RWMutex
|
||||
DiscordMuted bool
|
||||
DiscordWindow string
|
||||
DiscordSource string
|
||||
DiscordAction string
|
||||
LastOCRText string
|
||||
LastTranslate string
|
||||
CurrentWorld string
|
||||
CurrentWorldSince 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 SetDiscordWindow(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.DiscordWindow = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetDiscordSource(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.DiscordSource = v
|
||||
runtimeState.mu.Unlock()
|
||||
_ = persistRuntimeState()
|
||||
}
|
||||
|
||||
func SetDiscordAction(v string) {
|
||||
runtimeState.mu.Lock()
|
||||
runtimeState.DiscordAction = 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 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()
|
||||
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,
|
||||
"discord_window": s.DiscordWindow,
|
||||
"discord_source": s.DiscordSource,
|
||||
"discord_action": s.DiscordAction,
|
||||
"ocr": s.LastOCRText,
|
||||
"translate": s.LastTranslate,
|
||||
"world": s.CurrentWorld,
|
||||
"world_since": s.CurrentWorldSince,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
573
internal/app/vrc_log.go
Normal file
573
internal/app/vrc_log.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
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+(.+)$`)
|
||||
)
|
||||
|
||||
const vrchatLogTailBytes = 2 * 1024 * 1024
|
||||
|
||||
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() {
|
||||
log.Printf("vrchat log watcher started")
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("vrchat log watcher panic: %v", r)
|
||||
}
|
||||
}()
|
||||
lastPath := ""
|
||||
lastSize := int64(0)
|
||||
state := &vrcLogState{
|
||||
presentSet: map[string]struct{}{},
|
||||
}
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
log.Printf("vrchat log not found: %v", err)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log stat failed: %v", err)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
|
||||
if path != lastPath {
|
||||
log.Printf("vrchat log watching %s", path)
|
||||
lastPath = path
|
||||
lastSize = 0
|
||||
log.Printf("vrchat log initial scan begin path=%s", path)
|
||||
if err := scanExistingVrchatLog(path, state); err != nil {
|
||||
log.Printf("vrchat log initial scan failed: %v", err)
|
||||
} else {
|
||||
log.Printf("vrchat log initial scan ok path=%s", path)
|
||||
}
|
||||
if info2, err := os.Stat(path); err == nil {
|
||||
lastSize = info2.Size()
|
||||
} else {
|
||||
lastSize = info.Size()
|
||||
}
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Size() < lastSize {
|
||||
lastSize = 0
|
||||
}
|
||||
if info.Size() > lastSize {
|
||||
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||||
b, err := readFileFrom(path, lastSize)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log read failed: %v", err)
|
||||
time.Sleep(currentGUIRefreshInterval())
|
||||
continue
|
||||
}
|
||||
text := decodeVRChatLog(b)
|
||||
lines := strings.Split(text, "\n")
|
||||
joinHits := 0
|
||||
leaveHits := 0
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
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{}{}
|
||||
handleWorldChange(state, worldLabel, at)
|
||||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||||
}
|
||||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
log.Printf("join detected: %s", name)
|
||||
joinHits++
|
||||
state.presentSet[name] = struct{}{}
|
||||
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 {
|
||||
log.Printf("append join log failed: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||||
name := strings.TrimSpace(m[1])
|
||||
log.Printf("leave detected: %s", name)
|
||||
leaveHits++
|
||||
delete(state.presentSet, name)
|
||||
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 {
|
||||
log.Printf("append leave log failed: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
|
||||
lastSize = info.Size()
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
state.presentSet = map[string]struct{}{}
|
||||
state.location = ""
|
||||
state.worldID = ""
|
||||
state.instanceID = ""
|
||||
state.worldName = ""
|
||||
state.pendingWorldName = ""
|
||||
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])
|
||||
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
|
||||
}
|
||||
}
|
||||
state.initialized = true
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeVRChatLog(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(b) >= 2 {
|
||||
if b[0] == 0xff && b[1] == 0xfe {
|
||||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||
for i := 2; i+1 < len(b); i += 2 {
|
||||
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
|
||||
}
|
||||
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||||
}
|
||||
if b[0] == 0xfe && b[1] == 0xff {
|
||||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||
for i := 2; i+1 < len(b); i += 2 {
|
||||
u16 = append(u16, uint16(b[i+1])|uint16(b[i])<<8)
|
||||
}
|
||||
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\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(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 {
|
||||
return decodeVRChatLog(b)
|
||||
}
|
||||
|
||||
func fixMojibake(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{s}
|
||||
if repaired := tryRepairShiftJISMojibake(s); repaired != "" {
|
||||
candidates = append(candidates, repaired)
|
||||
}
|
||||
best := s
|
||||
bestScore := scoreReadable(best)
|
||||
for _, cand := range candidates {
|
||||
if score := scoreReadable(cand); score > bestScore {
|
||||
best = cand
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func tryRepairShiftJISMojibake(s string) string {
|
||||
raw := []byte(s)
|
||||
// Try the common "UTF-8 bytes interpreted as CP932" pattern repair by
|
||||
// round-tripping through CP932 in the reverse direction.
|
||||
if repaired := decodeWithCodePage(raw, 932); repaired != "" && repaired != s {
|
||||
return repaired
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scoreReadable(s string) int {
|
||||
if s == "" {
|
||||
return -1
|
||||
}
|
||||
score := 0
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<27>':
|
||||
score -= 8
|
||||
case r >= 0x3040 && r <= 0x30ff:
|
||||
score += 3
|
||||
case r >= 0x4e00 && r <= 0x9fff:
|
||||
score += 3
|
||||
case r >= 0x0020 && r <= 0x007e:
|
||||
score += 1
|
||||
case r == '\n' || r == '\r' || r == '\t':
|
||||
score += 1
|
||||
default:
|
||||
score -= 1
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func decodeWithCodePage(b []byte, codePage uint32) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
|
||||
n, _, _ := multiByteToWideChar.Call(
|
||||
uintptr(codePage),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&b[0])),
|
||||
uintptr(len(b)),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]uint16, n)
|
||||
multiByteToWideChar.Call(
|
||||
uintptr(codePage),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&b[0])),
|
||||
uintptr(len(b)),
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(len(buf)),
|
||||
)
|
||||
return syscall.UTF16ToString(buf)
|
||||
}
|
||||
|
||||
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 == "" && roomTitle != "" {
|
||||
nextLocation = roomTitle
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
state.initialized = true
|
||||
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()
|
||||
}
|
||||
|
||||
func handleWorldChange(state *vrcLogState, worldLabel string, at time.Time) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
SetCurrentWorldVisit(worldLabel, at)
|
||||
if tracker := GetWorldHistoryTracker(); tracker != nil {
|
||||
tracker.ObserveWorld(worldLabel, state.worldID, state.instanceID, at)
|
||||
}
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.ResetCurrentInstance(worldLabel)
|
||||
}
|
||||
}
|
||||
43
internal/app/vrc_log_test.go
Normal file
43
internal/app/vrc_log_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindLatestVrchatLogKeepsEmptyNewestFile(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
userProfile := filepath.Join(base, "profile")
|
||||
logDir := filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir log dir: %v", err)
|
||||
}
|
||||
t.Setenv("USERPROFILE", userProfile)
|
||||
|
||||
oldPath := filepath.Join(logDir, "output_log_2026-06-29_20-46-23.txt")
|
||||
newPath := filepath.Join(logDir, "output_log_2026-06-30_22-25-51.txt")
|
||||
if err := os.WriteFile(oldPath, []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("write old log: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write new log: %v", err)
|
||||
}
|
||||
oldTime := time.Date(2026, 6, 30, 22, 25, 52, 0, time.Local)
|
||||
newTime := time.Date(2026, 6, 30, 22, 26, 0, 0, time.Local)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("chtimes old log: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(newPath, newTime, newTime); err != nil {
|
||||
t.Fatalf("chtimes new log: %v", err)
|
||||
}
|
||||
|
||||
got, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
t.Fatalf("find latest log: %v", err)
|
||||
}
|
||||
if got != newPath {
|
||||
t.Fatalf("expected newest empty log, got %q want %q", got, newPath)
|
||||
}
|
||||
}
|
||||
260
internal/app/world_history.go
Normal file
260
internal/app/world_history.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
const worldHistoryFileName = "world_history.json"
|
||||
|
||||
type WorldVisit struct {
|
||||
WorldLabel string `json:"world_label"`
|
||||
WorldID string `json:"world_id,omitempty"`
|
||||
InstanceID string `json:"instance_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
type worldHistorySnapshot struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Visits []WorldVisit `json:"visits"`
|
||||
}
|
||||
|
||||
type WorldHistoryTracker struct {
|
||||
mu sync.RWMutex
|
||||
current *WorldVisit
|
||||
visits []WorldVisit
|
||||
}
|
||||
|
||||
var worldHistoryTracker *WorldHistoryTracker
|
||||
|
||||
func InitWorldHistoryTracker(t *WorldHistoryTracker) {
|
||||
worldHistoryTracker = t
|
||||
}
|
||||
|
||||
func GetWorldHistoryTracker() *WorldHistoryTracker {
|
||||
return worldHistoryTracker
|
||||
}
|
||||
|
||||
func NewWorldHistoryTracker() *WorldHistoryTracker {
|
||||
t := &WorldHistoryTracker{}
|
||||
_ = t.load()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) load() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
path := filepath.Join(dir, worldHistoryFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var snap worldHistorySnapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return err
|
||||
}
|
||||
t.visits = mergeWorldVisits(snap.Visits)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) ObserveWorld(worldLabel, worldID, instanceID string, at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
label := normalizeWorldLabel(worldLabel, worldID, instanceID)
|
||||
key := worldVisitKey(worldID, instanceID, label)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.current != nil && t.currentKey() == key {
|
||||
if t.current.WorldLabel == "" {
|
||||
t.current.WorldLabel = label
|
||||
}
|
||||
if t.current.WorldID == "" {
|
||||
t.current.WorldID = worldID
|
||||
}
|
||||
if t.current.InstanceID == "" {
|
||||
t.current.InstanceID = instanceID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if t.current != nil {
|
||||
if t.current.EndedAt.IsZero() {
|
||||
t.current.EndedAt = at
|
||||
}
|
||||
t.visits = append(t.visits, *t.current)
|
||||
}
|
||||
|
||||
t.current = &WorldVisit{
|
||||
WorldLabel: label,
|
||||
WorldID: worldID,
|
||||
InstanceID: instanceID,
|
||||
StartedAt: at,
|
||||
}
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) FinalizeCurrent(at time.Time) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.current == nil {
|
||||
return
|
||||
}
|
||||
if t.current.EndedAt.IsZero() {
|
||||
t.current.EndedAt = at
|
||||
}
|
||||
t.visits = append(t.visits, *t.current)
|
||||
t.current = nil
|
||||
_ = t.persistLocked()
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) Current() (WorldVisit, bool) {
|
||||
if t == nil {
|
||||
return WorldVisit{}, false
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
if t.current == nil {
|
||||
return WorldVisit{}, false
|
||||
}
|
||||
return *t.current, true
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) Snapshot() []WorldVisit {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]WorldVisit, 0, len(t.visits)+1)
|
||||
out = append(out, mergeWorldVisits(t.visits)...)
|
||||
if t.current != nil {
|
||||
out = append(out, *t.current)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ti := out[i].EndedAt
|
||||
if ti.IsZero() {
|
||||
ti = out[i].StartedAt
|
||||
}
|
||||
tj := out[j].EndedAt
|
||||
if tj.IsZero() {
|
||||
tj = out[j].StartedAt
|
||||
}
|
||||
if ti.Equal(tj) {
|
||||
return strings.ToLower(out[i].WorldLabel) < strings.ToLower(out[j].WorldLabel)
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) currentKey() string {
|
||||
if t.current == nil {
|
||||
return ""
|
||||
}
|
||||
return worldVisitKey(t.current.WorldID, t.current.InstanceID, t.current.WorldLabel)
|
||||
}
|
||||
|
||||
func (t *WorldHistoryTracker) persistLocked() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, worldHistoryFileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(worldHistorySnapshot{
|
||||
UpdatedAt: time.Now(),
|
||||
Visits: mergeWorldVisits(t.visits),
|
||||
})
|
||||
}
|
||||
|
||||
func mergeWorldVisits(visits []WorldVisit) []WorldVisit {
|
||||
if len(visits) <= 1 {
|
||||
return append([]WorldVisit(nil), visits...)
|
||||
}
|
||||
type bucket struct {
|
||||
visit WorldVisit
|
||||
}
|
||||
seen := make(map[string]int, len(visits))
|
||||
out := make([]bucket, 0, len(visits))
|
||||
for _, visit := range visits {
|
||||
key := worldVisitKey(visit.WorldID, visit.InstanceID, visit.WorldLabel) + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
if idx, ok := seen[key]; ok {
|
||||
if visit.EndedAt.After(out[idx].visit.EndedAt) {
|
||||
out[idx].visit.EndedAt = visit.EndedAt
|
||||
}
|
||||
if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" {
|
||||
out[idx].visit.WorldLabel = visit.WorldLabel
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[key] = len(out)
|
||||
out = append(out, bucket{visit: visit})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ti := out[i].visit.EndedAt
|
||||
if ti.IsZero() {
|
||||
ti = out[i].visit.StartedAt
|
||||
}
|
||||
tj := out[j].visit.EndedAt
|
||||
if tj.IsZero() {
|
||||
tj = out[j].visit.StartedAt
|
||||
}
|
||||
if ti.Equal(tj) {
|
||||
return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel)
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
merged := make([]WorldVisit, 0, len(out))
|
||||
for _, item := range out {
|
||||
merged = append(merged, item.visit)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func worldVisitKey(worldID, instanceID, worldLabel string) string {
|
||||
if worldID != "" {
|
||||
if instanceID != "" {
|
||||
return worldID + ":" + instanceID
|
||||
}
|
||||
return worldID
|
||||
}
|
||||
return strings.TrimSpace(worldLabel)
|
||||
}
|
||||
|
||||
func normalizeWorldLabel(worldLabel, worldID, instanceID string) string {
|
||||
label := strings.TrimSpace(worldLabel)
|
||||
if label != "" {
|
||||
return label
|
||||
}
|
||||
if worldID != "" {
|
||||
if instanceID != "" {
|
||||
return worldID + ":" + instanceID
|
||||
}
|
||||
return worldID
|
||||
}
|
||||
return "(unknown)"
|
||||
}
|
||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
26
internal/common/project_paths.go
Normal file
26
internal/common/project_paths.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var rootDirFunc = RuntimeBaseDir
|
||||
|
||||
func RuntimeBaseDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func RootDir() string { return rootDirFunc() }
|
||||
|
||||
func SetRootDirForTest(fn func() string) func() {
|
||||
prev := rootDirFunc
|
||||
rootDirFunc = fn
|
||||
return func() {
|
||||
rootDirFunc = prev
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,20 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vrc_osc_go/internal/common"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
OSC OSCConfig
|
||||
VrcLog VrcLogConfig
|
||||
GUI GUIConfig
|
||||
}
|
||||
|
||||
type OSCConfig struct {
|
||||
@@ -16,8 +23,49 @@ type OSCConfig struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
type VrcLogConfig struct {
|
||||
SelfName string
|
||||
GuestNames []string
|
||||
GuestFile string
|
||||
StaffNames []string
|
||||
MissingCount int
|
||||
LogPatterns []string
|
||||
}
|
||||
|
||||
type GUIConfig struct {
|
||||
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}}
|
||||
cfg := &Config{
|
||||
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||
GUI: GUIConfig{
|
||||
FontSize: 18,
|
||||
RefreshIntervalValue: 2,
|
||||
RefreshIntervalUnit: "sec",
|
||||
HistoryExportIncludeTime: true,
|
||||
HistoryExportIncludeWorld: true,
|
||||
HistoryExportIncludeJoinLeave: true,
|
||||
},
|
||||
}
|
||||
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
|
||||
@@ -28,6 +76,7 @@ func Load(path string) (*Config, error) {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
@@ -40,9 +89,9 @@ func Load(path string) (*Config, error) {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.Trim(val, "\"'")
|
||||
if section == "osc" {
|
||||
val := parseConfigValue(parts[1])
|
||||
switch section {
|
||||
case "osc":
|
||||
switch key {
|
||||
case "host":
|
||||
cfg.OSC.Host = val
|
||||
@@ -51,8 +100,260 @@ func Load(path string) (*Config, error) {
|
||||
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
|
||||
}
|
||||
case "gui":
|
||||
switch key {
|
||||
case "top_most":
|
||||
cfg.GUI.TopMost = parseBool(val, cfg.GUI.TopMost)
|
||||
case "font_size":
|
||||
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||
cfg.GUI.FontSize = n
|
||||
}
|
||||
case "auto_unmute_on_self_leave":
|
||||
cfg.GUI.AutoUnmuteOnSelfLeave = parseBool(val, cfg.GUI.AutoUnmuteOnSelfLeave)
|
||||
case "refresh_interval_value":
|
||||
if n, err := strconv.Atoi(val); err == nil && n > 0 {
|
||||
cfg.GUI.RefreshIntervalValue = n
|
||||
}
|
||||
case "refresh_interval_unit":
|
||||
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(val)
|
||||
case "history_regex":
|
||||
cfg.GUI.HistoryRegex = val
|
||||
case "history_from":
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.GUI.RefreshIntervalValue <= 0 {
|
||||
cfg.GUI.RefreshIntervalValue = 2
|
||||
}
|
||||
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)
|
||||
}
|
||||
if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
|
||||
cfg.VrcLog.GuestNames = names
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
content := upsertGUISection(string(data), gui)
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
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 parseBool(value string, fallback bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return true
|
||||
case "false", "0", "off", "no":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePath(path string) string {
|
||||
if path == "" {
|
||||
return filepath.Join(common.RootDir(), "config", "config.toml")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return filepath.Join(common.RootDir(), path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func upsertGUISection(existing string, gui GUIConfig) string {
|
||||
normalized := strings.ReplaceAll(existing, "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
out := make([]string, 0, len(lines)+6)
|
||||
inGUI := false
|
||||
replaced := false
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
section := strings.Trim(trimmed, "[]")
|
||||
if section == "gui" {
|
||||
if !replaced {
|
||||
appendGUISection(&out, gui)
|
||||
replaced = true
|
||||
}
|
||||
inGUI = true
|
||||
continue
|
||||
}
|
||||
if inGUI {
|
||||
inGUI = false
|
||||
}
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
if inGUI {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !replaced {
|
||||
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||
out = append(out, "")
|
||||
}
|
||||
appendGUISection(&out, gui)
|
||||
}
|
||||
content := strings.Join(out, "\n")
|
||||
content = strings.TrimRight(content, "\n")
|
||||
return content + "\n"
|
||||
}
|
||||
|
||||
func appendGUISection(out *[]string, gui GUIConfig) {
|
||||
gui.RefreshIntervalUnit = normalizeRefreshIntervalUnit(gui.RefreshIntervalUnit)
|
||||
if gui.RefreshIntervalValue <= 0 {
|
||||
gui.RefreshIntervalValue = 2
|
||||
}
|
||||
*out = append(*out,
|
||||
"[gui]",
|
||||
fmt.Sprintf("top_most = %t", gui.TopMost),
|
||||
fmt.Sprintf("font_size = %d", gui.FontSize),
|
||||
fmt.Sprintf("auto_unmute_on_self_leave = %t", gui.AutoUnmuteOnSelfLeave),
|
||||
fmt.Sprintf("refresh_interval_value = %d", gui.RefreshIntervalValue),
|
||||
fmt.Sprintf("refresh_interval_unit = %q", gui.RefreshIntervalUnit),
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeRefreshIntervalUnit(unit string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(unit)) {
|
||||
case "min", "minute", "minutes":
|
||||
return "min"
|
||||
default:
|
||||
return "sec"
|
||||
}
|
||||
}
|
||||
|
||||
func (g GUIConfig) RefreshInterval() time.Duration {
|
||||
value := g.RefreshIntervalValue
|
||||
if value <= 0 {
|
||||
value = 2
|
||||
}
|
||||
switch normalizeRefreshIntervalUnit(g.RefreshIntervalUnit) {
|
||||
case "min":
|
||||
return time.Duration(value) * time.Minute
|
||||
default:
|
||||
return time.Duration(value) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHistoryDate(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -31,3 +32,44 @@ 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\"\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" || 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveGUIUpdatesSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
initial := []byte("[self]\nname = \"alice\"\n\n[gui]\ntop_most = false\nfont_size = 18\n")
|
||||
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", 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" || 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)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "[self]") || !strings.Contains(text, "name = \"alice\"") {
|
||||
t.Fatalf("non-gui config content was lost: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
144
internal/consenttool/config.go
Normal file
144
internal/consenttool/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
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))
|
||||
}
|
||||
183
internal/consenttool/tool.go
Normal file
183
internal/consenttool/tool.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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 ""
|
||||
}
|
||||
44
internal/consenttool/tool_test.go
Normal file
44
internal/consenttool/tool_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package osc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,7 +35,12 @@ func NewServer(host string, port int) *Server {
|
||||
}
|
||||
|
||||
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) Close() error {
|
||||
if s.conn != nil {
|
||||
return s.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Serve() error {
|
||||
addr, err := net.ResolveUDPAddr("udp", s.addr)
|
||||
@@ -65,13 +72,70 @@ func (s *Server) Serve() error {
|
||||
}
|
||||
|
||||
func parseMessage(b []byte) (string, []Value, error) {
|
||||
parts := bytes.SplitN(b, []byte{0}, 2)
|
||||
if len(parts) == 0 {
|
||||
return "", nil, fmt.Errorf("invalid osc")
|
||||
address, offset, err := parsePaddedString(b, 0)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
address := string(parts[0])
|
||||
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
|
||||
}
|
||||
|
||||
106
internal/update/manager.go
Normal file
106
internal/update/manager.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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"
|
||||
}
|
||||
125
internal/update/release.go
Normal file
125
internal/update/release.go
Normal file
@@ -0,0 +1,125 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user