feat(settings): 支持自定义并持久化日志目录

- 将日志目录配置整合到数据目录设置,支持选择、打开、恢复默认与保存
- 持久化日志目录并保留环境变量优先级,目录调整后重启生效
- 通过跨进程锁和原子写入保护数据目录与日志目录配置
- 移除重复标题并互斥目录修改操作,补齐六语言文案
- 补充桌面接口、浏览器模拟及并发回归测试
This commit is contained in:
Syngnat
2026-07-23 11:40:17 +08:00
parent 5a5fbf04fe
commit 97cffa7809
28 changed files with 1459 additions and 38 deletions

View File

@@ -424,13 +424,17 @@ func dataRootInfoPayload(activeRoot string) map[string]interface{} {
if currentRoot == "" {
currentRoot = appdata.MustResolveActiveRoot()
}
return map[string]interface{}{
payload := map[string]interface{}{
"path": currentRoot,
"defaultPath": defaultRoot,
"driverPath": appdata.DriverRoot(currentRoot),
"isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot),
"bootstrapPath": appdata.BootstrapPath(),
}
for key, value := range logDirectoryInfoPayload() {
payload[key] = value
}
return payload
}
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {

View File

@@ -20,7 +20,8 @@ import (
)
var dataRootMigrationExcludedEntries = map[string]struct{}{
"storage_root.json": {},
filepath.Base(appdata.BootstrapPath()): {},
filepath.Base(appdata.BootstrapLockPath()): {},
}
type dataRootTextFunc func(string, map[string]any) string

View File

@@ -25,6 +25,20 @@ func methodsDataRootFunctionSource(t *testing.T, source string, signature string
return source[start : start+len(signature)+end]
}
func methodsLogDirectoryFunctionSource(t *testing.T, source string, signature string) string {
t.Helper()
start := strings.Index(source, signature)
if start < 0 {
t.Fatalf("methods_log_directory.go missing function signature %q", signature)
}
rest := source[start+len(signature):]
end := strings.Index(rest, "\nfunc ")
if end < 0 {
return source[start:]
}
return source[start : start+len(signature)+end]
}
func TestMethodsDataRootMessagesUseLocalizedText(t *testing.T) {
sourceBytes, err := os.ReadFile("methods_data_root.go")
if err != nil {
@@ -140,6 +154,78 @@ func TestMethodsDataRootMessagesUseLocalizedText(t *testing.T) {
}
}
func TestMethodsLogDirectoryMessagesUseLocalizedText(t *testing.T) {
sourceBytes, err := os.ReadFile("methods_log_directory.go")
if err != nil {
t.Fatalf("read methods_log_directory.go: %v", err)
}
source := string(sourceBytes)
checks := map[string]struct {
rawMessages []string
keys []string
}{
"func (a *App) SelectLogDirectory": {
rawMessages: []string{
`Message: "日志目录设置仅可在桌面应用中使用"`,
`Message: "日志目录由环境变量 GONAVI_LOG_DIR 管理"`,
`Title: "选择 GoNavi 日志目录"`,
},
keys: []string{
"app.data_root.log_directory.backend.error.desktop_only",
"app.data_root.log_directory.backend.error.environment_managed",
"app.data_root.log_directory.backend.dialog.select_directory",
},
},
"func (a *App) ApplyLogDirectory": {
rawMessages: []string{
`Message: "日志目录设置仅可在桌面应用中使用"`,
`Message: "日志目录由环境变量 GONAVI_LOG_DIR 管理"`,
`Message: fmt.Sprintf("保存日志目录失败:%v", err)`,
`message := "日志目录已保存,重启应用后生效"`,
`message = "日志目录未发生变化"`,
},
keys: []string{
"app.data_root.log_directory.backend.error.desktop_only",
"app.data_root.log_directory.backend.error.environment_managed",
"app.data_root.log_directory.backend.error.save_failed",
"app.data_root.log_directory.backend.message.updated_restart",
"app.data_root.log_directory.backend.message.unchanged",
},
},
"func (a *App) OpenLogDirectory": {
rawMessages: []string{
`Message: "日志目录设置仅可在桌面应用中使用"`,
`Message: "当前日志目录不存在或不可访问"`,
`Message: fmt.Sprintf("当前平台暂不支持打开日志目录:%s", stdRuntime.GOOS)`,
`Message: fmt.Sprintf("打开日志目录失败:%v", err)`,
`Message: "已打开日志目录"`,
},
keys: []string{
"app.data_root.log_directory.backend.error.desktop_only",
"app.data_root.log_directory.backend.error.directory_unavailable",
"app.data_root.log_directory.backend.error.open_directory_unsupported",
"app.data_root.log_directory.backend.error.open_directory_failed",
"app.data_root.log_directory.backend.message.opened",
},
},
}
for signature, check := range checks {
body := methodsLogDirectoryFunctionSource(t, source, signature)
for _, raw := range check.rawMessages {
if strings.Contains(body, raw) {
t.Fatalf("%s still contains raw log-directory text %q", signature, raw)
}
}
for _, key := range check.keys {
if !strings.Contains(body, key) {
t.Fatalf("%s should reference localized key %q", signature, key)
}
}
}
}
func TestMethodsDataRootCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
@@ -186,6 +272,35 @@ func TestMethodsDataRootCatalogKeysExist(t *testing.T) {
}
}
func TestMethodsLogDirectoryCatalogKeysExistInAllLanguages(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"app.data_root.log_directory.backend.dialog.select_directory",
"app.data_root.log_directory.backend.error.desktop_only",
"app.data_root.log_directory.backend.error.directory_unavailable",
"app.data_root.log_directory.backend.error.environment_managed",
"app.data_root.log_directory.backend.error.open_directory_failed",
"app.data_root.log_directory.backend.error.open_directory_unsupported",
"app.data_root.log_directory.backend.error.save_failed",
"app.data_root.log_directory.backend.message.opened",
"app.data_root.log_directory.backend.message.unchanged",
"app.data_root.log_directory.backend.message.updated_restart",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing log-directory key %q", language, key)
}
}
}
}
func TestApplyDataRootDirectoryUsesEnglishLocalizedMessageWhenUnchanged(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)

View File

@@ -50,6 +50,14 @@ func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
if err := os.WriteFile(filepath.Join(sourceRoot, "jvm_diag_audit.jsonl"), []byte("jvm-diag-audit\n"), 0o644); err != nil {
t.Fatalf("write jvm_diag_audit.jsonl failed: %v", err)
}
for _, excludedName := range []string{
filepath.Base(appdata.BootstrapPath()),
filepath.Base(appdata.BootstrapLockPath()),
} {
if err := os.WriteFile(filepath.Join(sourceRoot, excludedName), []byte("bootstrap-only"), 0o600); err != nil {
t.Fatalf("write excluded bootstrap file %s: %v", excludedName, err)
}
}
if err := os.MkdirAll(filepath.Join(sourceRoot, "sessions"), 0o755); err != nil {
t.Fatalf("mkdir sessions failed: %v", err)
}
@@ -72,6 +80,14 @@ func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
if got, err := os.ReadFile(filepath.Join(targetRoot, "jvm_diag_audit.jsonl")); err != nil || string(got) != "jvm-diag-audit\n" {
t.Fatalf("expected jvm_diag_audit.jsonl to migrate, content=%q err=%v", string(got), err)
}
for _, excludedName := range []string{
filepath.Base(appdata.BootstrapPath()),
filepath.Base(appdata.BootstrapLockPath()),
} {
if _, err := os.Stat(filepath.Join(targetRoot, excludedName)); !os.IsNotExist(err) {
t.Fatalf("bootstrap file %s should not migrate, err=%v", excludedName, err)
}
}
}
func TestMigrateDataRootContentsReplacesAuditDirectoryWithoutStaleSidecars(t *testing.T) {

View File

@@ -0,0 +1,187 @@
package app
import (
"os"
"os/exec"
"path/filepath"
stdRuntime "runtime"
"strings"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func directoriesEqual(left string, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return left == right
}
left = filepath.Clean(left)
right = filepath.Clean(right)
if stdRuntime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func logDirectoryInfoPayload() map[string]interface{} {
directory, managedByEnvironment := logger.ConfiguredDirectory()
return buildLogDirectoryInfoPayload(directory, logger.DefaultDirectory(), logger.Path(), managedByEnvironment)
}
func buildLogDirectoryInfoPayload(directory string, defaultDirectory string, logFilePath string, managedByEnvironment bool) map[string]interface{} {
directory = strings.TrimSpace(directory)
defaultDirectory = strings.TrimSpace(defaultDirectory)
logFilePath = strings.TrimSpace(logFilePath)
activeDirectory := ""
if logFilePath != "" {
activeDirectory = filepath.Dir(logFilePath)
}
source := "custom"
if managedByEnvironment {
source = "environment"
} else if directoriesEqual(directory, defaultDirectory) {
source = "default"
}
return map[string]interface{}{
"logDirectory": directory,
"activeLogDirectory": activeDirectory,
"logFilePath": logFilePath,
"defaultLogDirectory": defaultDirectory,
"logDirectorySource": source,
"logDirectoryEditable": !managedByEnvironment,
"logDirectoryRestartRequired": !directoriesEqual(activeDirectory, directory),
}
}
func (a *App) SelectLogDirectory(currentDirectory string) connection.QueryResult {
if a.webRuntime {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.desktop_only", nil)}
}
configuredDirectory, managedByEnvironment := logger.ConfiguredDirectory()
if managedByEnvironment {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.environment_managed", nil)}
}
defaultDirectory := strings.TrimSpace(currentDirectory)
if defaultDirectory == "" {
defaultDirectory = configuredDirectory
}
if !filepath.IsAbs(defaultDirectory) {
if abs, err := filepath.Abs(defaultDirectory); err == nil {
defaultDirectory = abs
}
}
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: a.appText("app.data_root.log_directory.backend.dialog.select_directory", nil),
DefaultDirectory: defaultDirectory,
CanCreateDirectories: true,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "已取消"}
}
resolved, err := filepath.Abs(strings.TrimSpace(selection))
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{
Success: true,
Data: map[string]interface{}{
"directory": filepath.Clean(resolved),
},
}
}
func (a *App) ApplyLogDirectory(directory string) connection.QueryResult {
a.dataRootApplyMu.Lock()
defer a.dataRootApplyMu.Unlock()
if a.webRuntime {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.desktop_only", nil)}
}
currentDirectory, managedByEnvironment := logger.ConfiguredDirectory()
if managedByEnvironment {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.environment_managed", nil)}
}
target := strings.TrimSpace(directory)
if directoriesEqual(target, logger.DefaultDirectory()) {
target = ""
}
savedDirectory, err := appdata.SetConfiguredLogDirectory(target)
if err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.data_root.log_directory.backend.error.save_failed", map[string]any{
"detail": err.Error(),
}),
}
}
if savedDirectory == "" {
savedDirectory = logger.DefaultDirectory()
}
message := a.appText("app.data_root.log_directory.backend.message.updated_restart", nil)
if directoriesEqual(currentDirectory, savedDirectory) {
message = a.appText("app.data_root.log_directory.backend.message.unchanged", nil)
}
return connection.QueryResult{
Success: true,
Message: message,
Data: dataRootInfoPayload(a.configDir),
}
}
func (a *App) OpenLogDirectory() connection.QueryResult {
if a.webRuntime {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.desktop_only", nil)}
}
logFilePath := strings.TrimSpace(logger.Path())
directory := ""
if logFilePath != "" {
directory = filepath.Dir(logFilePath)
}
if directory == "" {
directory, _ = logger.ConfiguredDirectory()
}
if stat, err := os.Stat(directory); err != nil || !stat.IsDir() {
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.log_directory.backend.error.directory_unavailable", nil)}
}
var cmd *exec.Cmd
switch stdRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", directory)
case "windows":
cmd = exec.Command("explorer", directory)
case "linux":
cmd = exec.Command("xdg-open", directory)
default:
return connection.QueryResult{
Success: false,
Message: a.appText("app.data_root.log_directory.backend.error.open_directory_unsupported", map[string]any{
"platform": stdRuntime.GOOS,
}),
}
}
if err := cmd.Start(); err != nil {
logger.Error(err, "打开日志目录失败")
return connection.QueryResult{
Success: false,
Message: a.appText("app.data_root.log_directory.backend.error.open_directory_failed", map[string]any{
"detail": err.Error(),
}),
}
}
return connection.QueryResult{
Success: true,
Message: a.appText("app.data_root.log_directory.backend.message.opened", nil),
Data: dataRootInfoPayload(a.configDir),
}
}

View File

@@ -0,0 +1,124 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
)
func TestBuildLogDirectoryInfoPayloadMarksPendingRestart(t *testing.T) {
configured := filepath.Join(t.TempDir(), "configured")
active := filepath.Join(t.TempDir(), "active", "gonavi.log")
defaultDirectory := filepath.Join(t.TempDir(), "default")
payload := buildLogDirectoryInfoPayload(configured, defaultDirectory, active, false)
if payload["logDirectory"] != configured {
t.Fatalf("logDirectory = %#v, want %q", payload["logDirectory"], configured)
}
if payload["activeLogDirectory"] != filepath.Dir(active) {
t.Fatalf("activeLogDirectory = %#v, want %q", payload["activeLogDirectory"], filepath.Dir(active))
}
if payload["logDirectorySource"] != "custom" {
t.Fatalf("logDirectorySource = %#v, want custom", payload["logDirectorySource"])
}
if payload["logDirectoryEditable"] != true {
t.Fatalf("logDirectoryEditable = %#v, want true", payload["logDirectoryEditable"])
}
if payload["logDirectoryRestartRequired"] != true {
t.Fatalf("logDirectoryRestartRequired = %#v, want true", payload["logDirectoryRestartRequired"])
}
}
func TestBuildLogDirectoryInfoPayloadMarksEnvironmentManaged(t *testing.T) {
directory := filepath.Join(t.TempDir(), "environment")
payload := buildLogDirectoryInfoPayload(directory, filepath.Join(t.TempDir(), "default"), filepath.Join(directory, "gonavi.log"), true)
if payload["logDirectorySource"] != "environment" {
t.Fatalf("logDirectorySource = %#v, want environment", payload["logDirectorySource"])
}
if payload["logDirectoryEditable"] != false {
t.Fatalf("logDirectoryEditable = %#v, want false", payload["logDirectoryEditable"])
}
if payload["logDirectoryRestartRequired"] != false {
t.Fatalf("logDirectoryRestartRequired = %#v, want false", payload["logDirectoryRestartRequired"])
}
}
func TestApplyLogDirectoryRejectsFilePathWithoutChangingSetting(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("GONAVI_LOG_DIR", "")
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil {
t.Fatalf("write blocking path: %v", err)
}
result := NewApp().ApplyLogDirectory(blockingPath)
if result.Success {
t.Fatalf("ApplyLogDirectory should reject a file path: %+v", result)
}
configured, err := appdata.ResolveConfiguredLogDirectory()
if err != nil {
t.Fatalf("ResolveConfiguredLogDirectory returned error: %v", err)
}
if configured != "" {
t.Fatalf("failed apply changed log directory to %q", configured)
}
}
func TestApplyLogDirectoryRejectsEnvironmentManagedSetting(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("GONAVI_LOG_DIR", filepath.Join(t.TempDir(), "environment-logs"))
result := NewApp().ApplyLogDirectory(filepath.Join(t.TempDir(), "custom-logs"))
if result.Success {
t.Fatalf("ApplyLogDirectory should reject environment-managed setting: %+v", result)
}
}
func TestApplyLogDirectoryRejectsWebRuntime(t *testing.T) {
application := NewApp()
application.webRuntime = true
result := application.ApplyLogDirectory(filepath.Join(t.TempDir(), "custom-logs"))
if result.Success {
t.Fatalf("ApplyLogDirectory should reject web runtime: %+v", result)
}
}
func TestApplyLogDirectorySerializesWithDataRootRequests(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("GONAVI_LOG_DIR", "")
application := NewApp()
customLogDirectory := filepath.Join(t.TempDir(), "custom-logs")
application.dataRootApplyMu.Lock()
done := make(chan connection.QueryResult, 1)
go func() {
done <- application.ApplyLogDirectory(customLogDirectory)
}()
select {
case <-done:
application.dataRootApplyMu.Unlock()
t.Fatal("ApplyLogDirectory bypassed the shared data-root serialization lock")
case <-time.After(50 * time.Millisecond):
}
application.dataRootApplyMu.Unlock()
select {
case result := <-done:
if !result.Success {
t.Fatalf("serialized ApplyLogDirectory returned failure: %s", result.Message)
}
case <-time.After(5 * time.Second):
t.Fatal("serialized ApplyLogDirectory did not resume")
}
}

View File

@@ -0,0 +1,30 @@
//go:build !(darwin || dragonfly || freebsd || linux || netbsd || openbsd || windows)
package appdata
import "os"
type bootstrapFileLock struct {
file *os.File
}
func acquireBootstrapFileLock(path string) (*bootstrapFileLock, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
return &bootstrapFileLock{file: file}, nil
}
func (lock *bootstrapFileLock) Close() error {
if lock == nil || lock.file == nil {
return nil
}
err := lock.file.Close()
lock.file = nil
return err
}
func atomicReplaceBootstrapFile(source string, target string) error {
return os.Rename(source, target)
}

View File

@@ -0,0 +1,49 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd
package appdata
import (
"errors"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
type bootstrapFileLock struct {
file *os.File
}
func acquireBootstrapFileLock(path string) (*bootstrapFileLock, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
_ = file.Close()
return nil, err
}
_ = file.Chmod(0o600)
return &bootstrapFileLock{file: file}, nil
}
func (lock *bootstrapFileLock) Close() error {
if lock == nil || lock.file == nil {
return nil
}
unlockErr := unix.Flock(int(lock.file.Fd()), unix.LOCK_UN)
closeErr := lock.file.Close()
lock.file = nil
return errors.Join(unlockErr, closeErr)
}
func atomicReplaceBootstrapFile(source string, target string) error {
if err := os.Rename(source, target); err != nil {
return err
}
directory, err := os.Open(filepath.Dir(target))
if err != nil {
return err
}
return errors.Join(directory.Sync(), directory.Close())
}

View File

@@ -0,0 +1,68 @@
//go:build windows
package appdata
import (
"errors"
"os"
"golang.org/x/sys/windows"
)
type bootstrapFileLock struct {
file *os.File
overlapped windows.Overlapped
}
func acquireBootstrapFileLock(path string) (*bootstrapFileLock, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
lock := &bootstrapFileLock{file: file}
if err := windows.LockFileEx(
windows.Handle(file.Fd()),
windows.LOCKFILE_EXCLUSIVE_LOCK,
0,
1,
0,
&lock.overlapped,
); err != nil {
_ = file.Close()
return nil, err
}
_ = file.Chmod(0o600)
return lock, nil
}
func (lock *bootstrapFileLock) Close() error {
if lock == nil || lock.file == nil {
return nil
}
unlockErr := windows.UnlockFileEx(
windows.Handle(lock.file.Fd()),
0,
1,
0,
&lock.overlapped,
)
closeErr := lock.file.Close()
lock.file = nil
return errors.Join(unlockErr, closeErr)
}
func atomicReplaceBootstrapFile(source string, target string) error {
sourcePath, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
targetPath, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
return windows.MoveFileEx(
sourcePath,
targetPath,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
}

View File

@@ -6,14 +6,20 @@ import (
"os"
"path/filepath"
"strings"
"sync"
)
const bootstrapFileName = "storage_root.json"
const (
bootstrapFileName = "storage_root.json"
bootstrapLockFileName = bootstrapFileName + ".lock"
configuredLogFileName = "gonavi.log"
)
const dataRootEnvName = "GONAVI_DATA_ROOT"
var (
ErrSetActiveRootCreateDataDirectory = errors.New("create data directory failed")
ErrSetActiveRootCreateBootstrapDirectory = errors.New("create bootstrap directory failed")
bootstrapConfigMu sync.Mutex
)
type setActiveRootError struct {
@@ -57,7 +63,97 @@ func SetActiveRootErrorDetail(err error) error {
}
type bootstrapConfig struct {
DataRoot string `json:"dataRoot"`
DataRoot string `json:"dataRoot,omitempty"`
LogDirectory string `json:"logDirectory,omitempty"`
}
func readBootstrapConfig() (bootstrapConfig, error) {
data, err := os.ReadFile(BootstrapPath())
if err != nil {
if os.IsNotExist(err) {
return bootstrapConfig{}, nil
}
return bootstrapConfig{}, err
}
var cfg bootstrapConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return bootstrapConfig{}, err
}
return cfg, nil
}
func writeBootstrapConfig(cfg bootstrapConfig) error {
cfg.DataRoot = strings.TrimSpace(cfg.DataRoot)
cfg.LogDirectory = strings.TrimSpace(cfg.LogDirectory)
if cfg.DataRoot == "" && cfg.LogDirectory == "" {
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.MkdirAll(DefaultRoot(), 0o755); err != nil {
return err
}
payload, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return writeBootstrapConfigAtomic(payload)
}
func writeBootstrapConfigAtomic(payload []byte) error {
temporary, err := os.CreateTemp(DefaultRoot(), ".storage_root-*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(payload); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := atomicReplaceBootstrapFile(temporaryPath, BootstrapPath()); err != nil {
return err
}
cleanup = false
return nil
}
func updateBootstrapConfig(update func(*bootstrapConfig)) (err error) {
bootstrapConfigMu.Lock()
defer bootstrapConfigMu.Unlock()
if err := os.MkdirAll(DefaultRoot(), 0o755); err != nil {
return err
}
fileLock, err := acquireBootstrapFileLock(BootstrapLockPath())
if err != nil {
return err
}
defer func() {
err = errors.Join(err, fileLock.Close())
}()
cfg, err := readBootstrapConfig()
if err != nil {
return err
}
update(&cfg)
return writeBootstrapConfig(cfg)
}
func configuredRootOverride() string {
@@ -76,6 +172,10 @@ func BootstrapPath() string {
return filepath.Join(DefaultRoot(), bootstrapFileName)
}
func BootstrapLockPath() string {
return filepath.Join(DefaultRoot(), bootstrapLockFileName)
}
func normalizeRoot(root string) (string, error) {
trimmed := strings.TrimSpace(root)
if trimmed == "" {
@@ -100,15 +200,10 @@ func ResolveActiveRoot() (string, error) {
if err != nil {
return "", err
}
data, err := os.ReadFile(BootstrapPath())
bootstrapConfigMu.Lock()
cfg, err := readBootstrapConfig()
bootstrapConfigMu.Unlock()
if err != nil {
if os.IsNotExist(err) {
return defaultRoot, nil
}
return "", err
}
var cfg bootstrapConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return "", err
}
if strings.TrimSpace(cfg.DataRoot) == "" {
@@ -145,21 +240,66 @@ func SetActiveRoot(root string) (string, error) {
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateDataDirectory, err)
}
if targetRoot == defaultRoot {
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
return "", err
if targetRoot != defaultRoot {
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateBootstrapDirectory, err)
}
return defaultRoot, nil
}
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
return "", newSetActiveRootError(ErrSetActiveRootCreateBootstrapDirectory, err)
}
payload, err := json.MarshalIndent(bootstrapConfig{DataRoot: targetRoot}, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(BootstrapPath(), payload, 0o644); err != nil {
if err := updateBootstrapConfig(func(cfg *bootstrapConfig) {
if targetRoot == defaultRoot {
cfg.DataRoot = ""
} else {
cfg.DataRoot = targetRoot
}
}); err != nil {
return "", err
}
return targetRoot, nil
}
func ResolveConfiguredLogDirectory() (string, error) {
bootstrapConfigMu.Lock()
cfg, err := readBootstrapConfig()
bootstrapConfigMu.Unlock()
if err != nil {
return "", err
}
directory := strings.TrimSpace(cfg.LogDirectory)
if directory == "" {
return "", nil
}
abs, err := filepath.Abs(directory)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
func SetConfiguredLogDirectory(directory string) (string, error) {
target := strings.TrimSpace(directory)
if target != "" {
abs, err := filepath.Abs(target)
if err != nil {
return "", err
}
target = filepath.Clean(abs)
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
probe, err := os.OpenFile(filepath.Join(target, configuredLogFileName), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
if err := probe.Close(); err != nil {
return "", err
}
}
if err := updateBootstrapConfig(func(cfg *bootstrapConfig) {
cfg.LogDirectory = target
}); err != nil {
return "", err
}
return target, nil
}

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestResolveActiveRootDefaultsToLegacyGonaviDir(t *testing.T) {
@@ -89,3 +90,143 @@ func TestResolveActiveRootPrefersEnvOverride(t *testing.T) {
t.Fatalf("expected env override root %q, got %q", overrideRoot, resolvedRoot)
}
}
func TestDataRootAndLogDirectoryPreserveEachOtherInBootstrap(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
customDataRoot := filepath.Join(t.TempDir(), "gonavi-data")
customLogDirectory := filepath.Join(t.TempDir(), "gonavi-logs")
if _, err := SetConfiguredLogDirectory(customLogDirectory); err != nil {
t.Fatalf("SetConfiguredLogDirectory returned error: %v", err)
}
if _, err := SetActiveRoot(customDataRoot); err != nil {
t.Fatalf("SetActiveRoot returned error: %v", err)
}
resolvedLogDirectory, err := ResolveConfiguredLogDirectory()
if err != nil {
t.Fatalf("ResolveConfiguredLogDirectory returned error: %v", err)
}
if resolvedLogDirectory != customLogDirectory {
t.Fatalf("expected custom log directory %q, got %q", customLogDirectory, resolvedLogDirectory)
}
if _, err := SetActiveRoot(""); err != nil {
t.Fatalf("reset SetActiveRoot returned error: %v", err)
}
if _, err := os.Stat(BootstrapPath()); err != nil {
t.Fatalf("bootstrap should remain while log directory is customized: %v", err)
}
resolvedLogDirectory, err = ResolveConfiguredLogDirectory()
if err != nil || resolvedLogDirectory != customLogDirectory {
t.Fatalf("log directory after data-root reset = %q, %v", resolvedLogDirectory, err)
}
if _, err := SetActiveRoot(customDataRoot); err != nil {
t.Fatalf("restore custom data root returned error: %v", err)
}
if _, err := SetConfiguredLogDirectory(""); err != nil {
t.Fatalf("reset SetConfiguredLogDirectory returned error: %v", err)
}
resolvedRoot, err := ResolveActiveRoot()
if err != nil || resolvedRoot != customDataRoot {
t.Fatalf("data root after log reset = %q, %v", resolvedRoot, err)
}
if _, err := os.Stat(BootstrapPath()); err != nil {
t.Fatalf("bootstrap should remain while data root is customized: %v", err)
}
if _, err := SetActiveRoot(""); err != nil {
t.Fatalf("final SetActiveRoot reset returned error: %v", err)
}
if _, err := os.Stat(BootstrapPath()); !os.IsNotExist(err) {
t.Fatalf("bootstrap should be removed when both settings use defaults, got err=%v", err)
}
}
func TestSetConfiguredLogDirectoryRejectsFilePathWithoutChangingConfig(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil {
t.Fatalf("write blocking file: %v", err)
}
if _, err := SetConfiguredLogDirectory(blockingPath); err == nil {
t.Fatal("expected file path to be rejected as a log directory")
}
configured, err := ResolveConfiguredLogDirectory()
if err != nil {
t.Fatalf("ResolveConfiguredLogDirectory returned error: %v", err)
}
if configured != "" {
t.Fatalf("failed update changed configured log directory to %q", configured)
}
}
func TestSetConfiguredLogDirectoryRejectsDirectoryAtLogFilePathWithoutChangingConfig(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
targetDirectory := filepath.Join(t.TempDir(), "logs")
if err := os.MkdirAll(filepath.Join(targetDirectory, configuredLogFileName), 0o755); err != nil {
t.Fatalf("create blocking log-file directory: %v", err)
}
if _, err := SetConfiguredLogDirectory(targetDirectory); err == nil {
t.Fatal("expected a directory at the log file path to be rejected")
}
configured, err := ResolveConfiguredLogDirectory()
if err != nil {
t.Fatalf("ResolveConfiguredLogDirectory returned error: %v", err)
}
if configured != "" {
t.Fatalf("failed update changed configured log directory to %q", configured)
}
}
func TestBootstrapFileLockSerializesAccess(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "storage_root.json.lock")
first, err := acquireBootstrapFileLock(lockPath)
if err != nil {
t.Fatalf("acquire first bootstrap lock: %v", err)
}
defer first.Close()
acquired := make(chan *bootstrapFileLock, 1)
errs := make(chan error, 1)
go func() {
second, err := acquireBootstrapFileLock(lockPath)
if err != nil {
errs <- err
return
}
acquired <- second
}()
select {
case second := <-acquired:
_ = second.Close()
t.Fatal("second bootstrap lock acquired before the first was released")
case err := <-errs:
t.Fatalf("acquire second bootstrap lock: %v", err)
case <-time.After(50 * time.Millisecond):
}
if err := first.Close(); err != nil {
t.Fatalf("release first bootstrap lock: %v", err)
}
select {
case second := <-acquired:
if err := second.Close(); err != nil {
t.Fatalf("release second bootstrap lock: %v", err)
}
case err := <-errs:
t.Fatalf("acquire second bootstrap lock after release: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("second bootstrap lock did not acquire after the first was released")
}
}

View File

@@ -12,6 +12,8 @@ import (
"sync"
"sync/atomic"
"time"
"GoNavi-Wails/internal/appdata"
)
const (
@@ -147,6 +149,23 @@ func Path() string {
return logPath
}
func DefaultDirectory() string {
return defaultLogDir()
}
func ConfiguredDirectory() (string, bool) {
if directory := strings.TrimSpace(os.Getenv(envLogDir)); directory != "" {
if abs, err := filepath.Abs(directory); err == nil {
directory = filepath.Clean(abs)
}
return directory, true
}
if directory, err := appdata.ResolveConfiguredLogDirectory(); err == nil && directory != "" {
return directory, false
}
return defaultLogDir(), false
}
func Close() {
Init()
logMu.Lock()
@@ -263,14 +282,17 @@ func printf(level string, format string, args ...any) {
}
func initOutput() (string, io.Writer) {
dir := strings.TrimSpace(os.Getenv(envLogDir))
if dir == "" {
dir = defaultLogDir()
}
dir, _ := ConfiguredDirectory()
if path, writer, ok := openLogFile(dir); ok {
return path, writer
}
defaultDir := defaultLogDir()
if filepath.Clean(dir) != filepath.Clean(defaultDir) {
if path, writer, ok := openLogFile(defaultDir); ok {
return path, writer
}
}
fallbackDir := filepath.Join(os.TempDir(), appHiddenDir, appLogDirName)
if path, writer, ok := openLogFile(fallbackDir); ok {

View File

@@ -6,10 +6,13 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/appdata"
)
func TestMain(m *testing.M) {
@@ -34,6 +37,62 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestConfiguredDirectoryUsesPersistedSetting(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv(envLogDir, "")
customDirectory := filepath.Join(t.TempDir(), "custom-logs")
if _, err := appdata.SetConfiguredLogDirectory(customDirectory); err != nil {
t.Fatalf("SetConfiguredLogDirectory returned error: %v", err)
}
directory, managedByEnvironment := ConfiguredDirectory()
if managedByEnvironment {
t.Fatal("persisted log directory should not be marked as environment-managed")
}
if directory != customDirectory {
t.Fatalf("configured directory = %q, want %q", directory, customDirectory)
}
}
func TestConfiguredDirectoryPrefersEnvironmentOverride(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
persistedDirectory := filepath.Join(t.TempDir(), "persisted-logs")
if _, err := appdata.SetConfiguredLogDirectory(persistedDirectory); err != nil {
t.Fatalf("SetConfiguredLogDirectory returned error: %v", err)
}
environmentDirectory := filepath.Join(t.TempDir(), "environment-logs")
t.Setenv(envLogDir, environmentDirectory)
directory, managedByEnvironment := ConfiguredDirectory()
if !managedByEnvironment {
t.Fatal("environment log directory should be marked as environment-managed")
}
if directory != environmentDirectory {
t.Fatalf("configured directory = %q, want environment override %q", directory, environmentDirectory)
}
}
func TestConfiguredDirectoryFallsBackToDefault(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv(envLogDir, "")
directory, managedByEnvironment := ConfiguredDirectory()
if managedByEnvironment {
t.Fatal("default log directory should not be marked as environment-managed")
}
want := filepath.Join(homeDir, appHiddenDir, appLogDirName)
if directory != want {
t.Fatalf("configured directory = %q, want default %q", directory, want)
}
}
type slowSyncSink struct {
mu sync.Mutex
contents bytes.Buffer

View File

@@ -43,6 +43,9 @@ var desktopOnlyAppMethods = map[string]struct{}{
"GetDataRootDirectoryInfo": {},
"ApplyDataRootDirectory": {},
"OpenDataRootDirectory": {},
"SelectLogDirectory": {},
"ApplyLogDirectory": {},
"OpenLogDirectory": {},
"SelectDriverDownloadDirectory": {},
"SelectDriverPackageFile": {},
"SelectDriverPackageDirectory": {},

View File

@@ -106,7 +106,7 @@ func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T)
"Shutdown", "ExportSQLAuditFile", "OpenSQLFile", "ExecuteSQLFile", "ReadSQLFile",
"PreviewImportFile", "ImportDataWithProgress", "ImportDataWithProgressOptions", "GetDataRootDirectoryInfo",
"ExportDatabaseSQLWithOptions", "ExportSchemaSQLWithOptions",
"ApplyDataRootDirectory", "OpenDataRootDirectory", "SetApplicationBrandIcon",
"ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory", "SetApplicationBrandIcon",
} {
_, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method})
if err == nil || !strings.Contains(err.Error(), "unavailable in web runtime") {