Simplify GUI settings and build flags
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"vrc_osc_go/internal/config"
|
||||
"vrc_osc_go/internal/consenttool"
|
||||
"vrc_osc_go/internal/buildinfo"
|
||||
"vrc_osc_go/internal/osc"
|
||||
)
|
||||
|
||||
@@ -18,17 +19,25 @@ type discordMuteState struct {
|
||||
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))
|
||||
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
|
||||
|
||||
@@ -52,14 +61,25 @@ func Run(configPath string, mode string) error {
|
||||
}
|
||||
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() {
|
||||
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)
|
||||
|
||||
@@ -3,120 +3,87 @@ package app
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
vkControl = 0x11
|
||||
vkShift = 0x10
|
||||
vkM = 0x4D
|
||||
keyUp = 0x0002
|
||||
swRestore = 9
|
||||
)
|
||||
|
||||
func pressDiscordMuteHotkey() error {
|
||||
script := `
|
||||
$ErrorActionPreference = "Stop"
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
enumWindows := user32.NewProc("EnumWindows")
|
||||
getWindowTextW := user32.NewProc("GetWindowTextW")
|
||||
isWindowVisible := user32.NewProc("IsWindowVisible")
|
||||
setForegroundWindow := user32.NewProc("SetForegroundWindow")
|
||||
showWindow := user32.NewProc("ShowWindow")
|
||||
getWindowRect := user32.NewProc("GetWindowRect")
|
||||
keybdEvent := user32.NewProc("keybd_event")
|
||||
setCursorPos := user32.NewProc("SetCursorPos")
|
||||
sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep")
|
||||
|
||||
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()
|
||||
if (-not $title) { continue }
|
||||
if (-not $title.ToLower().Contains('discord')) { continue }
|
||||
$p
|
||||
}
|
||||
if (-not $windows) { return @() }
|
||||
return $windows
|
||||
}
|
||||
|
||||
function Get-BestDiscordWindow {
|
||||
$windows = Get-DiscordWindows
|
||||
if (-not $windows -or $windows.Count -eq 0) { return $null }
|
||||
|
||||
$browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
|
||||
$ranked = $windows | Sort-Object {
|
||||
$title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
|
||||
$score = 0
|
||||
foreach ($keyword in $browserKeywords) {
|
||||
if ($title.Contains($keyword)) { $score += 10 }
|
||||
}
|
||||
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
|
||||
if ($title.Contains('discord')) { $score += 5 }
|
||||
$score
|
||||
} -Descending
|
||||
|
||||
return $ranked | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Get-VRChatWindow {
|
||||
$windows = Get-Process | Where-Object {
|
||||
$_.MainWindowTitle -and (
|
||||
$_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ')
|
||||
)
|
||||
}
|
||||
if (-not $windows) { return $null }
|
||||
return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Activate-Window($p) {
|
||||
if ($null -eq $p) { return }
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
try { $null = $wshell.AppActivate($p.Id) } catch {}
|
||||
Start-Sleep -Milliseconds 200
|
||||
Click-WindowCenter $p
|
||||
}
|
||||
|
||||
$discord = Get-BestDiscordWindow
|
||||
if ($null -eq $discord) { exit 2 }
|
||||
|
||||
Activate-Window $discord
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
Start-Sleep -Milliseconds 200
|
||||
$wshell.SendKeys('^+m')
|
||||
Start-Sleep -Milliseconds 200
|
||||
|
||||
$vrchat = Get-VRChatWindow
|
||||
if ($null -ne $vrchat) {
|
||||
Activate-Window $vrchat
|
||||
}
|
||||
`
|
||||
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
log.Printf("discord mute output: %s", string(out))
|
||||
var target uintptr
|
||||
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
|
||||
vis, _, _ := isWindowVisible.Call(hwnd)
|
||||
if vis == 0 {
|
||||
return 1
|
||||
}
|
||||
buf := make([]uint16, 512)
|
||||
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
|
||||
if !strings.Contains(title, "discord") {
|
||||
return 1
|
||||
}
|
||||
target = hwnd
|
||||
return 0
|
||||
})
|
||||
r1, _, err1 := enumWindows.Call(cb, 0)
|
||||
if r1 == 0 && target == 0 {
|
||||
return fmt.Errorf("discord window not found: %v", err1)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord mute failed: %w", err)
|
||||
if target == 0 {
|
||||
return fmt.Errorf("discord window not found")
|
||||
}
|
||||
|
||||
showWindow.Call(target, swRestore)
|
||||
setForegroundWindow.Call(target)
|
||||
sleep.Call(150)
|
||||
|
||||
type rect struct {
|
||||
Left int32
|
||||
Top int32
|
||||
Right int32
|
||||
Bottom int32
|
||||
}
|
||||
var r rect
|
||||
if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 {
|
||||
x := int((r.Left + r.Right) / 2)
|
||||
y := int((r.Top + r.Bottom) / 2)
|
||||
setCursorPos.Call(uintptr(x), uintptr(y))
|
||||
sleep.Call(50)
|
||||
}
|
||||
|
||||
send := func(vk byte, flags uintptr) {
|
||||
keybdEvent.Call(uintptr(vk), 0, flags, 0)
|
||||
}
|
||||
send(vkControl, 0)
|
||||
send(vkShift, 0)
|
||||
send(vkM, 0)
|
||||
send(vkM, keyUp)
|
||||
send(vkShift, keyUp)
|
||||
send(vkControl, keyUp)
|
||||
sleep.Call(100)
|
||||
|
||||
SetDiscordWindow("Discord")
|
||||
log.Printf("discord hotkey sent via keybd_event")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,33 +30,25 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
|
||||
|
||||
if isPressed && !state.buttonPressed {
|
||||
state.buttonPressed = true
|
||||
if state.muted {
|
||||
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = false
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
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 {
|
||||
log.Printf("INFO already unmuted")
|
||||
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
|
||||
if !state.muted {
|
||||
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
|
||||
if err := pressDiscordMuteHotkey(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.muted = true
|
||||
SetDiscordMuted(state.muted)
|
||||
state.lastAction = now
|
||||
} else {
|
||||
log.Printf("INFO already muted")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,128 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runPythonOcrFromScreen() error {
|
||||
script := `
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
from ocr.ocr_actions import runOcrFromScreen
|
||||
runOcrFromScreen()
|
||||
`
|
||||
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("ocr output: %s", string(out))
|
||||
SetOCRText(string(out))
|
||||
SetTranslateText("翻訳: 未実装")
|
||||
log.Printf("python output: %s", string(out))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ocr failed: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,13 +2,45 @@ 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 setupRuntimeLogger() error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runtime.log")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logWriter := &runtimeLogWriter{file: f}
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendRuntimeLog(title, text string) error {
|
||||
dir := filepath.Join(common.RootDir(), "runtime")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -50,3 +82,43 @@ func appendJoinLeaveLog(text string) error {
|
||||
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
|
||||
return err
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
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()
|
||||
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func AppendDesktopJoinLog(title, worldLabel, text string) error {
|
||||
return appendDesktopJoinLog(title, worldLabel, text)
|
||||
}
|
||||
|
||||
@@ -38,12 +38,15 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
|
||||
if nextState == "joined" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
SetDiscordSource("self_monitor_error")
|
||||
}
|
||||
} else if nextState == "left" {
|
||||
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
|
||||
log.Printf("self monitor discord mute failed: %v", err)
|
||||
SetDiscordSource("self_monitor_error")
|
||||
}
|
||||
}
|
||||
SetDiscordSource("self_monitor")
|
||||
lastState = nextState
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
type RuntimeState struct {
|
||||
mu sync.RWMutex
|
||||
DiscordMuted bool
|
||||
DiscordWindow string
|
||||
DiscordSource string
|
||||
DiscordAction string
|
||||
LastOCRText string
|
||||
LastTranslate string
|
||||
CurrentWorld string
|
||||
@@ -39,6 +42,27 @@ func SetDiscordMuted(v bool) {
|
||||
_ = 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
|
||||
@@ -81,6 +105,9 @@ func persistRuntimeState() error {
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -9,6 +8,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -77,6 +79,12 @@ func findLatestVrchatLog() (string, error) {
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -85,20 +93,26 @@ func watchVrchatLog() {
|
||||
for {
|
||||
path, err := findLatestVrchatLog()
|
||||
if err != nil {
|
||||
log.Printf("vrchat log not found: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log stat failed: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
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()
|
||||
@@ -113,15 +127,22 @@ func watchVrchatLog() {
|
||||
lastSize = 0
|
||||
}
|
||||
if info.Size() > lastSize {
|
||||
f, err := os.Open(path)
|
||||
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("vrchat log read failed: %v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
_, _ = f.Seek(lastSize, 0)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
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)
|
||||
}
|
||||
@@ -137,41 +158,47 @@ func watchVrchatLog() {
|
||||
at := extractLineTime(line)
|
||||
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)
|
||||
}
|
||||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(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)
|
||||
}
|
||||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||||
log.Print(out)
|
||||
_ = appendJoinLeaveLog(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()
|
||||
_ = f.Close()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
f, err := os.Open(path)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
state.presentSet = map[string]struct{}{}
|
||||
state.location = ""
|
||||
state.worldID = ""
|
||||
@@ -180,9 +207,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
state.pendingWorldName = ""
|
||||
state.initialized = false
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
text := decodeVRChatLog(b)
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if changed, _ := updateWorldState(state, line); changed {
|
||||
state.presentSet = map[string]struct{}{}
|
||||
}
|
||||
@@ -203,9 +233,6 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
state.initialized = true
|
||||
if state.worldName != "" {
|
||||
log.Printf("world changed: %s", state.worldName)
|
||||
@@ -213,11 +240,130 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||||
if tracker := GetGuestTracker(); tracker != nil {
|
||||
tracker.SetCurrentInstance(state.worldName)
|
||||
}
|
||||
_ = appendRuntimeLog("VRC WORLD", state.worldName)
|
||||
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 utf8.Valid(b) {
|
||||
return strings.TrimPrefix(string(b), "\ufeff")
|
||||
}
|
||||
if len(b) >= 2 {
|
||||
if b[0] == 0xff && b[1] == 0xfe {
|
||||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||||
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")
|
||||
}
|
||||
}
|
||||
for _, cp := range []uint32{932, 1252, 65001} {
|
||||
if decoded := decodeWithCodePage(b, cp); decoded != "" {
|
||||
return strings.TrimPrefix(fixMojibake(decoded), "\ufeff")
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(fixMojibake(string(b)), "\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 := ""
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var rootDirFunc = RuntimeBaseDir
|
||||
|
||||
func RuntimeBaseDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
@@ -13,4 +15,12 @@ func RuntimeBaseDir() string {
|
||||
return "."
|
||||
}
|
||||
|
||||
func RootDir() string { return RuntimeBaseDir() }
|
||||
func RootDir() string { return rootDirFunc() }
|
||||
|
||||
func SetRootDirForTest(fn func() string) func() {
|
||||
prev := rootDirFunc
|
||||
rootDirFunc = fn
|
||||
return func() {
|
||||
rootDirFunc = prev
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
type Config struct {
|
||||
OSC OSCConfig
|
||||
VrcLog VrcLogConfig
|
||||
GUI GUIConfig
|
||||
}
|
||||
|
||||
type OSCConfig struct {
|
||||
@@ -29,9 +31,15 @@ type VrcLogConfig struct {
|
||||
LogPatterns []string
|
||||
}
|
||||
|
||||
type GUIConfig struct {
|
||||
TopMost bool
|
||||
FontSize int
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
|
||||
GUI: GUIConfig{FontSize: 18},
|
||||
}
|
||||
if path == "" {
|
||||
path = filepath.Join(common.RootDir(), "config", "config.toml")
|
||||
@@ -98,6 +106,15 @@ func Load(path string) (*Config, error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.VrcLog.GuestFile != "" {
|
||||
@@ -111,6 +128,19 @@ func Load(path string) (*Config, error) {
|
||||
return cfg, scanner.Err()
|
||||
}
|
||||
|
||||
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, "[")
|
||||
@@ -129,6 +159,76 @@ func parseList(value string) []string {
|
||||
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) {
|
||||
*out = append(*out,
|
||||
"[gui]",
|
||||
fmt.Sprintf("top_most = %t", gui.TopMost),
|
||||
fmt.Sprintf("font_size = %d", gui.FontSize),
|
||||
)
|
||||
}
|
||||
|
||||
func loadLines(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != 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\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 {
|
||||
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}); 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user