feat: add portable directory safeguards

This commit is contained in:
QiuSW
2026-07-28 00:12:29 +08:00
parent 92091a6690
commit 733954ddc8
15 changed files with 914 additions and 95 deletions
+27
View File
@@ -3,6 +3,7 @@ package domain
import (
"errors"
"path/filepath"
"strings"
"testing"
)
@@ -90,3 +91,29 @@ func TestNormalizeProxyNameRequiresNonEmptyDisplayText(t *testing.T) {
t.Fatalf("empty proxy name error = %v", err)
}
}
func TestNormalizeInstanceNameRejectsUnsafeWindowsNames(t *testing.T) {
if got, err := NormalizeInstanceName(" 审核 Edge "); err != nil || got != "审核 Edge" {
t.Fatalf("NormalizeInstanceName() = %q, %v", got, err)
}
for _, value := range []string{"", "CON", "aux.txt", "审核?", "审核.", "审核\\Edge"} {
if _, err := NormalizeInstanceName(value); err == nil {
t.Fatalf("unsafe instance name %q was accepted", value)
}
}
}
func TestInstanceProfileDirectoryNameIsReadableAndStablePerID(t *testing.T) {
first, err := InstanceProfileDirectoryName("审核 Edge", "instance-one")
if err != nil || !strings.HasPrefix(first, "instance-审核 Edge-") {
t.Fatalf("first directory name = %q, %v", first, err)
}
second, err := InstanceProfileDirectoryName("审核 Edge", "instance-two")
if err != nil || second == first {
t.Fatalf("second directory name = %q, %v", second, err)
}
long, err := InstanceProfileDirectoryName(strings.Repeat("审", 80), "instance-long")
if err != nil || len(long) > len("instance-")+64+1+8 {
t.Fatalf("long directory name = %q, %v", long, err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package domain
import (
"errors"
"fmt"
"hash/fnv"
"strings"
"unicode/utf8"
)
const maxInstanceNameRunes = 80
var errInvalidInstanceName = errors.New("invalid instance name")
// NormalizeInstanceName validates the user-visible instance name before it is
// stored or used to derive a Windows profile directory. The validation keeps
// the display name readable instead of silently rewriting user input.
func NormalizeInstanceName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", fmt.Errorf("%w: name is required", errInvalidInstanceName)
}
if utf8.RuneCountInString(name) > maxInstanceNameRunes {
return "", fmt.Errorf("%w: name is too long", errInvalidInstanceName)
}
if strings.HasSuffix(name, ".") {
return "", fmt.Errorf("%w: name cannot end with a period", errInvalidInstanceName)
}
for _, value := range name {
if value < 0x20 || strings.ContainsRune(`<>:"/\\|?*`, value) {
return "", fmt.Errorf("%w: name contains a Windows-reserved character", errInvalidInstanceName)
}
}
base := strings.ToUpper(strings.Split(name, ".")[0])
if isReservedWindowsDeviceName(base) {
return "", fmt.Errorf("%w: name is a reserved Windows device name", errInvalidInstanceName)
}
return name, nil
}
// InstanceProfileDirectoryName derives a compact, readable, and unique
// profile directory component. The stable hash keeps paths distinct even
// when display names share a truncated prefix.
func InstanceProfileDirectoryName(name, instanceID string) (string, error) {
name, err := NormalizeInstanceName(name)
if err != nil {
return "", err
}
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return "", fmt.Errorf("%w: instance ID is required", errInvalidInstanceName)
}
segment := truncateUTF8(name, 64)
hash := fnv.New32a()
_, _ = hash.Write([]byte(instanceID))
return fmt.Sprintf("instance-%s-%08x", segment, hash.Sum32()), nil
}
func truncateUTF8(value string, limit int) string {
if len(value) <= limit {
return value
}
count := 0
for index := range value {
if index > limit {
break
}
count = index
}
if count == 0 {
return value
}
return value[:count]
}
func isReservedWindowsDeviceName(value string) bool {
switch value {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return true
default:
return false
}
}
+194 -18
View File
@@ -17,13 +17,34 @@ const currentVersion = 1
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
type DirectorySource string
const (
DirectorySourcePortable DirectorySource = "portable"
DirectorySourceCustom DirectorySource = "custom"
)
const (
legacyDefaultUserDataDir = `C:\Users\Public\chub\profiles`
legacyLogDir = `C:\Users\Public\chub\logs`
)
type Settings struct {
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"`
LogDir string `json:"logDir"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"`
DefaultDirSource DirectorySource `json:"defaultUserDataDirSource,omitempty"`
LogDir string `json:"logDir"`
LogDirSource DirectorySource `json:"logDirSource,omitempty"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
}
// PortableDirectories are resolved from the actual executable location for
// the default store, never from the process working directory.
type PortableDirectories struct {
DefaultDir string
LogDir string
}
type Instance struct {
@@ -68,14 +89,25 @@ func DefaultPath() (string, error) {
return defaultPathForExecutable(executable)
}
// DefaultPortableDirectories resolves the portable defaults beside the actual
// executable. It is useful before config.json can be opened so the UI can
// still offer a recoverable manual-path flow.
func DefaultPortableDirectories() (PortableDirectories, error) {
executable, err := os.Executable()
if err != nil {
return PortableDirectories{}, fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForExecutable(executable)
}
// DefaultInstanceUserDataRoot returns the portable root for new browser
// profiles. It resolves the actual executable location and never uses cwd.
func DefaultInstanceUserDataRoot() (string, error) {
executable, err := os.Executable()
directories, err := DefaultPortableDirectories()
if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
return "", err
}
return defaultInstanceUserDataRootForExecutable(executable)
return directories.DefaultDir, nil
}
// DefaultInstanceUserDataDir returns a unique-instance profile path below the
@@ -120,6 +152,12 @@ func OpenDefault() (*Store, error) {
func (s *Store) Path() string { return s.path }
// PortableDirectories resolves defaults relative to this store's config.json
// location. OpenDefault always places that file beside chub.exe.
func (s *Store) PortableDirectories() (PortableDirectories, error) {
return portableDirectoriesForConfigPath(s.path)
}
func defaultPathForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
@@ -129,11 +167,39 @@ func defaultPathForExecutable(executable string) (string, error) {
}
func defaultInstanceUserDataRootForExecutable(executable string) (string, error) {
directories, err := portableDirectoriesForExecutable(executable)
if err != nil {
return "", err
}
return directories.DefaultDir, nil
}
func portableDirectoriesForExecutable(executable string) (PortableDirectories, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
return PortableDirectories{}, fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Dir(filepath.Clean(path)), "user_data_dirs"), nil
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForConfigPath(configPath string) (PortableDirectories, error) {
path := strings.TrimSpace(configPath)
if path == "" || !filepath.IsAbs(path) || !strings.EqualFold(filepath.Base(path), "config.json") {
return PortableDirectories{}, fmt.Errorf("%w: config path must be an absolute config.json path", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForBase(base string) (PortableDirectories, error) {
base = strings.TrimSpace(base)
if base == "" || !filepath.IsAbs(base) {
return PortableDirectories{}, fmt.Errorf("%w: portable base path must be absolute", ErrDefaultConfigUnavailable)
}
base = filepath.Clean(base)
return PortableDirectories{
DefaultDir: filepath.Join(base, "user_data_dirs"),
LogDir: filepath.Join(base, "logs"),
}, nil
}
func defaultInstanceUserDataDir(root, instanceID string) (string, error) {
@@ -202,7 +268,11 @@ func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
func (s *Store) Load() (File, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return File{Version: currentVersion, Settings: DefaultSettings()}, nil
settings, settingsErr := s.defaultSettings()
if settingsErr != nil {
return File{}, settingsErr
}
return File{Version: currentVersion, Settings: settings}, nil
}
if err != nil {
return File{}, fmt.Errorf("read config: %w", err)
@@ -217,12 +287,15 @@ func (s *Store) Load() (File, error) {
if result.Version != currentVersion {
return File{}, fmt.Errorf("unsupported config version %d", result.Version)
}
if result.Settings.RemoteDebugStartPort == 0 {
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
if err := s.normalizeSettings(&result.Settings); err != nil {
return File{}, err
}
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
return File{}, err
}
if err := normalizeInstanceNames(&result); err != nil {
return File{}, err
}
if err := normalizeProxyConfig(&result); err != nil {
return File{}, err
}
@@ -230,11 +303,29 @@ func (s *Store) Load() (File, error) {
}
func DefaultSettings() Settings {
directories, err := DefaultPortableDirectories()
if err != nil {
return Settings{RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
}
return defaultSettingsForDirectories(directories)
}
func (s *Store) defaultSettings() (Settings, error) {
directories, err := s.PortableDirectories()
if err != nil {
return Settings{}, err
}
return defaultSettingsForDirectories(directories), nil
}
func defaultSettingsForDirectories(directories PortableDirectories) Settings {
return Settings{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
DefaultDir: `C:\Users\Public\chub\profiles`,
LogDir: `C:\Users\Public\chub\logs`,
DefaultDir: directories.DefaultDir,
DefaultDirSource: DirectorySourcePortable,
LogDir: directories.LogDir,
LogDirSource: DirectorySourcePortable,
RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
CloseOnExit: true,
}
@@ -242,8 +333,8 @@ func DefaultSettings() Settings {
func (s *Store) Save(value File) error {
value.Version = currentVersion
if value.Settings.RemoteDebugStartPort == 0 {
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
if err := s.normalizeSettings(&value.Settings); err != nil {
return err
}
if value.Instances == nil {
value.Instances = []Instance{}
@@ -254,6 +345,9 @@ func (s *Store) Save(value File) error {
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
return err
}
if err := normalizeInstanceNames(&value); err != nil {
return err
}
if err := normalizeProxyConfig(&value); err != nil {
return err
}
@@ -291,6 +385,88 @@ func (s *Store) Save(value File) error {
return nil
}
func (s *Store) normalizeSettings(settings *Settings) error {
if settings == nil {
return errors.New("settings are required")
}
directories, err := s.PortableDirectories()
if err != nil {
return err
}
defaultDir, defaultSource, err := normalizeDirectorySetting(settings.DefaultDir, settings.DefaultDirSource, directories.DefaultDir, legacyDefaultUserDataDir)
if err != nil {
return fmt.Errorf("invalid default user data directory: %w", err)
}
logDir, logSource, err := normalizeDirectorySetting(settings.LogDir, settings.LogDirSource, directories.LogDir, legacyLogDir)
if err != nil {
return fmt.Errorf("invalid log directory: %w", err)
}
settings.DefaultDir = defaultDir
settings.DefaultDirSource = defaultSource
settings.LogDir = logDir
settings.LogDirSource = logSource
if settings.RemoteDebugStartPort == 0 {
settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
return nil
}
func normalizeDirectorySetting(value string, source DirectorySource, portableDefault, legacyDefault string) (string, DirectorySource, error) {
value = strings.TrimSpace(value)
source = DirectorySource(strings.ToLower(strings.TrimSpace(string(source))))
if source == "" {
if value == "" || sameDirectory(value, portableDefault) || sameDirectory(value, legacyDefault) {
source = DirectorySourcePortable
} else {
source = DirectorySourceCustom
}
}
switch source {
case DirectorySourcePortable:
return filepath.Clean(portableDefault), DirectorySourcePortable, nil
case DirectorySourceCustom:
if value == "" || !filepath.IsAbs(value) {
return "", "", errors.New("custom directory must be absolute")
}
return filepath.Clean(value), DirectorySourceCustom, nil
default:
return "", "", errors.New("directory source must be portable or custom")
}
}
func sameDirectory(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
func normalizeInstanceNames(value *File) error {
if value == nil {
return errors.New("config is required")
}
seen := make(map[string]struct{}, len(value.Instances))
for index := range value.Instances {
instance := &value.Instances[index]
name, err := domain.NormalizeInstanceName(instance.Name)
if err != nil {
return errors.New("invalid instance name configuration")
}
if _, err := domain.InstanceProfileDirectoryName(name, instance.ID); err != nil {
return errors.New("invalid instance name configuration")
}
key := strings.ToUpper(name)
if _, exists := seen[key]; exists {
return errors.New("duplicate instance name configuration")
}
seen[key] = struct{}{}
instance.Name = name
}
return nil
}
func normalizeInstanceRemoteDebugPorts(value *File) error {
if value == nil {
return errors.New("config is required")
+52 -7
View File
@@ -23,7 +23,7 @@ func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Version != currentVersion || got.Settings != want.Settings || len(got.Instances) != 1 || got.Instances[0].ID != "a" {
if got.Version != currentVersion || got.Settings.ChromePath != want.Settings.ChromePath || got.Settings.RemoteDebugStartPort != want.Settings.RemoteDebugStartPort || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable || len(got.Instances) != 1 || got.Instances[0].ID != "a" {
t.Fatalf("round trip mismatch: %#v", got)
}
}
@@ -45,7 +45,7 @@ func TestStoreAddsDefaultRemoteDebugPortForExistingConfig(t *testing.T) {
func TestStoreAssignsPreferredPortsForLegacyInstancesInStableOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","name":"第一实例","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","name":"第二实例","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
@@ -75,13 +75,13 @@ func TestStoreRejectsInvalidOrDuplicatePreferredPorts(t *testing.T) {
t.Fatal(err)
}
duplicate := File{Instances: []Instance{
{ID: "one", PreferredRemoteDebugPort: 9666},
{ID: "two", PreferredRemoteDebugPort: 9666},
{ID: "one", Name: "一号", PreferredRemoteDebugPort: 9666},
{ID: "two", Name: "二号", PreferredRemoteDebugPort: 9666},
}}
if err := store.Save(duplicate); err == nil {
t.Fatal("duplicate preferred port was accepted")
}
invalid := File{Instances: []Instance{{ID: "one", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
invalid := File{Instances: []Instance{{ID: "one", Name: "一号", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
if err := store.Save(invalid); err == nil {
t.Fatal("invalid preferred port was accepted")
}
@@ -134,6 +134,51 @@ func TestDefaultInstanceUserDataDirectoryUsesExecutableDirectoryAndStableID(t *t
}
}
func TestStoreMigratesHistoricalDirectoriesToPortableDefaults(t *testing.T) {
base := filepath.Join(t.TempDir(), "published")
store, err := New(filepath.Join(base, "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Save(File{Settings: Settings{DefaultDir: legacyDefaultUserDataDir, LogDir: legacyLogDir}}); err != nil {
t.Fatal(err)
}
got, err := store.Load()
if err != nil {
t.Fatal(err)
}
if got.Settings.DefaultDir != filepath.Join(base, "user_data_dirs") || got.Settings.LogDir != filepath.Join(base, "logs") || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable {
t.Fatalf("portable migration = %#v", got.Settings)
}
custom := filepath.Join(t.TempDir(), "custom-profiles")
got.Settings.DefaultDir = custom
got.Settings.DefaultDirSource = DirectorySourceCustom
if err := store.Save(got); err != nil {
t.Fatal(err)
}
reloaded, err := store.Load()
if err != nil || reloaded.Settings.DefaultDir != custom || reloaded.Settings.DefaultDirSource != DirectorySourceCustom {
t.Fatalf("custom directory = %#v, error = %v", reloaded.Settings, err)
}
}
func TestStoreRejectsInvalidAndDuplicateInstanceNames(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
for _, instances := range [][]Instance{
{{ID: "one", Name: "审核"}, {ID: "two", Name: "审核"}},
{{ID: "one", Name: "Audit"}, {ID: "two", Name: "aUDIT"}},
{{ID: "one", Name: "CON"}},
} {
if err := store.Save(File{Instances: instances}); err == nil {
t.Fatalf("unsafe instance configuration persisted: %#v", instances)
}
}
}
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
@@ -176,7 +221,7 @@ func TestOpenDefaultAtPrefersExistingTargetOverLegacy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", PreferredRemoteDebugPort: 9666}}}); err != nil {
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", Name: "目标实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
@@ -241,7 +286,7 @@ func TestOpenDefaultAtRejectsUncreatableTargetWithoutTouchingLegacy(t *testing.T
if err != nil {
t.Fatal(err)
}
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", PreferredRemoteDebugPort: 9666}}}); err != nil {
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", Name: "旧实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err)
}
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
@@ -0,0 +1,104 @@
package files
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
var ErrInstanceDataDirectoryUnavailable = errors.New("instance data directory is unavailable")
// DirectoryPreparer verifies one explicitly supplied absolute directory.
// It is used for program-owned locations such as the configured log folder.
type DirectoryPreparer interface {
PrepareDirectory(context.Context, string) error
}
// InstanceDataPreparer creates and verifies only the User Data Dir belonging
// to the explicitly requested instance. It never discovers or changes other
// browser data directories.
type InstanceDataPreparer interface {
PrepareInstanceDataDir(context.Context, string) error
}
type instanceDataPreparer struct {
mkdirAll func(string, os.FileMode) error
lstat func(string) (os.FileInfo, error)
createTemp func(string, string) (*os.File, error)
remove func(string) error
}
func NewInstanceDataPreparer() InstanceDataPreparer {
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
}
func NewDirectoryPreparer() DirectoryPreparer {
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
}
func newInstanceDataPreparer(
mkdirAll func(string, os.FileMode) error,
lstat func(string) (os.FileInfo, error),
createTemp func(string, string) (*os.File, error),
remove func(string) error,
) instanceDataPreparer {
return instanceDataPreparer{mkdirAll: mkdirAll, lstat: lstat, createTemp: createTemp, remove: remove}
}
func (p instanceDataPreparer) PrepareInstanceDataDir(ctx context.Context, userDataDir string) error {
return p.PrepareDirectory(ctx, userDataDir)
}
func (p instanceDataPreparer) PrepareDirectory(ctx context.Context, directory string) error {
if err := ctx.Err(); err != nil {
return err
}
path, err := normalizePreparatoryInstanceDataDir(directory)
if err != nil {
return err
}
if err := p.mkdirAll(path, 0o700); err != nil {
return fmt.Errorf("%w: create", ErrInstanceDataDirectoryUnavailable)
}
if err := ctx.Err(); err != nil {
return err
}
info, err := p.lstat(path)
if err != nil {
return fmt.Errorf("%w: inspect", ErrInstanceDataDirectoryUnavailable)
}
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || isReparsePoint(info) {
return fmt.Errorf("%w: links and reparse points are not supported", ErrUnsafeInstanceDataDirectory)
}
if !info.IsDir() {
return ErrInstanceDataNotDirectory
}
probe, err := p.createTemp(path, ".chub-write-*")
if err != nil {
return fmt.Errorf("%w: write", ErrInstanceDataDirectoryUnavailable)
}
probePath := probe.Name()
if err := probe.Close(); err != nil {
_ = p.remove(probePath)
return fmt.Errorf("%w: close", ErrInstanceDataDirectoryUnavailable)
}
if err := p.remove(probePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: cleanup", ErrInstanceDataDirectoryUnavailable)
}
if err := ctx.Err(); err != nil {
return err
}
return nil
}
// normalizePreparatoryInstanceDataDir is kept separate from deletion's
// helper so callers can use a stable, intention-revealing validation entry.
func normalizePreparatoryInstanceDataDir(value string) (string, error) {
path, err := normalizeRemovableInstanceDataDir(value)
if err != nil {
return "", err
}
return filepath.Clean(path), nil
}
@@ -81,6 +81,36 @@ func TestInstanceDataRemoverHonorsCanceledContext(t *testing.T) {
}
}
func TestInstanceDataPreparerCreatesAndVerifiesWritableDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "new-profile")
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(context.Background(), target); err != nil {
t.Fatal(err)
}
info, err := os.Stat(target)
if err != nil || !info.IsDir() {
t.Fatalf("prepared directory = %#v, error = %v", info, err)
}
probes, err := filepath.Glob(filepath.Join(target, ".chub-write-*"))
if err != nil || len(probes) != 0 {
t.Fatalf("write probes = %#v, error = %v", probes, err)
}
}
func TestInstanceDataPreparerRejectsUnavailableDirectoryBeforeBrowserLaunch(t *testing.T) {
target := filepath.Join(t.TempDir(), "profile")
preparer := newInstanceDataPreparer(os.MkdirAll, os.Lstat, func(string, string) (*os.File, error) {
return nil, errors.New("denied")
}, os.Remove)
if err := preparer.PrepareInstanceDataDir(context.Background(), target); !errors.Is(err, ErrInstanceDataDirectoryUnavailable) {
t.Fatalf("unwritable directory error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(ctx, filepath.Join(t.TempDir(), "canceled")); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled preparation error = %v", err)
}
}
type fakeFileInfo struct {
mode os.FileMode
sys any
+16
View File
@@ -3,6 +3,8 @@ package logging
import (
"io"
"log/slog"
"os"
"path/filepath"
)
// New creates the process logger. Callers provide the sink so tests and the
@@ -13,3 +15,17 @@ func New(w io.Writer) *slog.Logger {
}
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
// OpenFile creates an append-only local diagnostic log in the configured
// directory. It never falls back to cwd, so a bad configured directory stays
// visible to the caller instead of scattering logs beside a shortcut.
func OpenFile(directory string) (*slog.Logger, io.Closer, error) {
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, nil, err
}
file, err := os.OpenFile(filepath.Join(directory, "chub.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, nil, err
}
return New(file), file, nil
}
+18
View File
@@ -2,6 +2,8 @@ package logging
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -20,3 +22,19 @@ func TestNewWritesStructuredLog(t *testing.T) {
func TestNewNilWriterDoesNotPanic(t *testing.T) {
New(nil).Info("discarded")
}
func TestOpenFileWritesInsideConfiguredDirectory(t *testing.T) {
directory := filepath.Join(t.TempDir(), "logs")
logger, closer, err := OpenFile(directory)
if err != nil {
t.Fatal(err)
}
logger.Info("file event")
if err := closer.Close(); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(directory, "chub.log"))
if err != nil || !strings.Contains(string(contents), `"msg":"file event"`) {
t.Fatalf("log contents = %q, error = %v", contents, err)
}
}
+255 -47
View File
@@ -242,6 +242,8 @@ type directoryPickTarget uint8
const (
directoryPickCreate directoryPickTarget = iota
directoryPickEdit
directoryPickDefaultUserData
directoryPickLogDirectory
)
type proxyPickerTarget uint8
@@ -278,7 +280,9 @@ type SettingsState struct {
ChromePath string
EdgePath string
DefaultDir string
DefaultDirMode string
LogDir string
LogDirMode string
RemoteDebugStartPort int
ReservedRemoteDebugPorts []int
CloseOnExit bool
@@ -304,8 +308,10 @@ type Shell struct {
edgeSearch widget.Clickable
dataPick widget.Clickable
dataSearch widget.Clickable
dataRestore widget.Clickable
logPick widget.Clickable
logSearch widget.Clickable
logRestore widget.Clickable
saveClick widget.Clickable
cancelClick widget.Clickable
@@ -399,6 +405,13 @@ type Shell struct {
nextInstance uint64
pendingCreateID string
defaultInstanceDirRoot string
portableDefaultDir string
portableLogDir string
defaultDirMode string
logDirMode string
lastSavedSettings SettingsState
defaultCreateDirActive bool
lastSuggestedCreateDir string
instanceFeedback string
pendingDeleteID string
editingID string
@@ -455,6 +468,10 @@ func NewShell(theme *material.Theme) *Shell {
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.portableDefaultDir = s.dataDir.Text()
s.portableLogDir = s.logDir.Text()
s.defaultDirMode = "portable"
s.logDirMode = "portable"
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
s.closeOnExit.Value = true
@@ -464,6 +481,7 @@ func NewShell(theme *material.Theme) *Shell {
s.list.Axis = layout.Vertical
s.settingsList.Axis = layout.Vertical
s.tabsList.Axis = layout.Vertical
s.lastSavedSettings = SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), DefaultDirMode: s.defaultDirMode, LogDir: s.logDir.Text(), LogDirMode: s.logDirMode, RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
return s
}
@@ -518,17 +536,52 @@ func (s *Shell) SetDefaultInstanceUserDataRoot(root string) {
s.defaultInstanceDirRoot = filepath.Clean(root)
}
// SetPortableDirectoryDefaults supplies paths already resolved from chub.exe.
// It deliberately accepts only strings so the Gio shell never reads the file
// system while rendering or handling user input.
func (s *Shell) SetPortableDirectoryDefaults(defaultDir, logDir string) {
defaultDir = strings.TrimSpace(defaultDir)
logDir = strings.TrimSpace(logDir)
if defaultDir != "" && filepath.IsAbs(defaultDir) {
s.portableDefaultDir = filepath.Clean(defaultDir)
if s.defaultDirMode == "" || s.defaultDirMode == "portable" {
s.defaultDirMode = "portable"
s.dataDir.SetText(s.portableDefaultDir)
s.SetDefaultInstanceUserDataRoot(s.portableDefaultDir)
}
}
if logDir != "" && filepath.IsAbs(logDir) {
s.portableLogDir = filepath.Clean(logDir)
if s.logDirMode == "" || s.logDirMode == "portable" {
s.logDirMode = "portable"
s.logDir.SetText(s.portableLogDir)
}
}
}
func (s *Shell) SetSettings(value SettingsState) {
s.chromePath.SetText(value.ChromePath)
s.edgePath.SetText(value.EdgePath)
s.dataDir.SetText(value.DefaultDir)
s.logDir.SetText(value.LogDir)
s.defaultDirMode = normalizeDirectoryMode(value.DefaultDirMode)
s.logDirMode = normalizeDirectoryMode(value.LogDirMode)
if s.defaultDirMode == "portable" && s.portableDefaultDir != "" {
s.dataDir.SetText(s.portableDefaultDir)
}
if s.logDirMode == "portable" && s.portableLogDir != "" {
s.logDir.SetText(s.portableLogDir)
}
if root := strings.TrimSpace(s.dataDir.Text()); root != "" && filepath.IsAbs(root) {
s.SetDefaultInstanceUserDataRoot(root)
}
port := value.RemoteDebugStartPort
if port == 0 {
port = domain.DefaultRemoteDebugPort
}
s.remoteDebugPort.SetText(strconv.Itoa(port))
s.closeOnExit.Value = value.CloseOnExit
s.lastSavedSettings = SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), DefaultDirMode: s.defaultDirMode, LogDir: s.logDir.Text(), LogDirMode: s.logDirMode, RemoteDebugStartPort: port, CloseOnExit: s.closeOnExit.Value}
}
// ReportStartupIssue records a safe, user-recoverable configuration message.
@@ -620,6 +673,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeControls(gtx)
}
s.presentUnexpectedExitIfReady()
s.syncDefaultCreateDirectory()
mainLayout := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar),
@@ -730,12 +784,15 @@ func (s *Shell) beginCreate() {
s.page = pageCreate
s.formFeedback = ""
s.pendingCreateID = s.allocateInstanceID()
s.defaultCreateDirActive = true
s.lastSuggestedCreateDir = ""
s.instanceName.SetText("")
s.instanceURL.SetText("")
s.createProxyID = ""
s.browserKind.Value = "chrome"
if defaultDir := s.defaultCreateInstanceDir(s.pendingCreateID); defaultDir != "" {
if defaultDir := s.defaultCreateInstanceDir("新实例", s.pendingCreateID); defaultDir != "" {
s.instanceDir.SetText(defaultDir)
s.lastSuggestedCreateDir = defaultDir
} else {
s.instanceDir.SetText("")
s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。"
@@ -904,9 +961,9 @@ func (s *Shell) saveEdit() {
s.closeEdit("要编辑的实例已不存在。")
return
}
name := strings.TrimSpace(s.editName.Text())
if name == "" {
s.editFeedback = "请输入实例名称。"
name, err := s.validateInstanceName(s.editName.Text(), row.ID)
if err != nil {
s.editFeedback = err.Error()
return
}
targetURL, err := normalizeTargetURL(s.editURL.Text())
@@ -1358,7 +1415,7 @@ func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimen
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;修改实例名称不会移动已有目录").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
@@ -1962,17 +2019,23 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
s.togglePathSearch(PathEdgeExecutable)
}
for s.dataPick.Clicked(gtx) {
s.pathFeedback = "请选择默认 User Data Dir"
s.chooseDirectory(directoryPickDefaultUserData)
}
for s.dataSearch.Clicked(gtx) {
s.togglePathSearch(PathDefaultUserData)
}
for s.logPick.Clicked(gtx) {
s.pathFeedback = "请选择日志目录"
s.chooseDirectory(directoryPickLogDirectory)
}
for s.logSearch.Clicked(gtx) {
s.togglePathSearch(PathLogDirectory)
}
for s.dataRestore.Clicked(gtx) {
s.restorePortableDirectory(PathDefaultUserData)
}
for s.logRestore.Clicked(gtx) {
s.restorePortableDirectory(PathLogDirectory)
}
for s.proxySave.Clicked(gtx) {
s.saveProxy()
}
@@ -1986,6 +2049,10 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
continue
}
s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort))
s.defaultDirMode = settings.DefaultDirMode
s.logDirMode = settings.LogDirMode
s.defaultInstanceDirRoot = settings.DefaultDir
s.lastSavedSettings = settings
if s.onSave != nil {
s.onSave(settings)
}
@@ -2018,9 +2085,9 @@ func (s *Shell) settingsWorkspace(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.settingsSection("默认目录", "这些目录只作为新建实例和诊断日志的默认位置,不会移动或删除已有 profile。", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
layout.Rigid(s.portableDirectoryField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch, &s.dataRestore)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
layout.Rigid(s.portableDirectoryField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch, &s.logRestore)),
)
})),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
@@ -2182,6 +2249,26 @@ func (s *Shell) pathField(field PathField, label string, editor *widget.Editor,
}
}
func (s *Shell) portableDirectoryField(field PathField, label string, editor *widget.Editor, pick, search, restore *widget.Clickable) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
mode := s.directoryMode(field)
help := "自定义目录;仅影响后续新建实例或后续日志写入。"
if mode == "portable" {
help = "跟随程序目录;下次启动会按 chub.exe 所在目录重新解析。"
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.pathField(field, label, editor, pick, search)),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, material.Caption(s.theme, help).Layout),
layout.Rigid(material.Button(s.theme, restore, "恢复程序目录默认").Layout),
)
}),
)
}
}
func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "新建实例").Layout),
@@ -2446,7 +2533,7 @@ func (s *Shell) proxyPickerNoneLabel() string {
func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout),
layout.Rigid(material.Caption(s.theme, "默认目录会使用 instance-实例名-短 ID;必须是绝对路径").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
@@ -2459,11 +2546,20 @@ func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
}
func (s *Shell) createInstanceFromForm() {
name := strings.TrimSpace(s.instanceName.Text())
if name == "" {
s.formFeedback = "请输入实例名称后再创建。"
name, err := s.validateInstanceName(s.instanceName.Text(), "")
if err != nil {
s.formFeedback = err.Error()
return
}
if s.defaultCreateDirActive {
defaultDir := s.defaultCreateInstanceDir(name, s.pendingCreateID)
if defaultDir == "" {
s.formFeedback = "无法根据实例名称生成默认 User Data Dir;请选择或输入一个绝对路径。"
return
}
s.instanceDir.SetText(defaultDir)
s.lastSuggestedCreateDir = defaultDir
}
userDataDir := strings.TrimSpace(s.instanceDir.Text())
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。"
@@ -2495,14 +2591,66 @@ func (s *Shell) createInstanceFromForm() {
s.page = pageInstances
s.formFeedback = ""
s.pendingCreateID = ""
s.defaultCreateDirActive = false
s.lastSuggestedCreateDir = ""
s.notifyInstancesChanged()
}
func (s *Shell) defaultCreateInstanceDir(instanceID string) string {
func (s *Shell) defaultCreateInstanceDir(name, instanceID string) string {
if s.defaultInstanceDirRoot == "" || instanceID == "" {
return ""
}
return filepath.Join(s.defaultInstanceDirRoot, instanceID)
component, err := domain.InstanceProfileDirectoryName(name, instanceID)
if err != nil {
return ""
}
return filepath.Join(s.defaultInstanceDirRoot, component)
}
func (s *Shell) syncDefaultCreateDirectory() {
if !s.defaultCreateDirActive || s.pendingCreateID == "" {
return
}
current := strings.TrimSpace(s.instanceDir.Text())
if s.lastSuggestedCreateDir != "" && !samePathText(current, s.lastSuggestedCreateDir) {
s.defaultCreateDirActive = false
return
}
name := strings.TrimSpace(s.instanceName.Text())
if name == "" {
name = "新实例"
}
suggested := s.defaultCreateInstanceDir(name, s.pendingCreateID)
if suggested == "" || samePathText(suggested, s.lastSuggestedCreateDir) {
return
}
s.instanceDir.SetText(suggested)
s.lastSuggestedCreateDir = suggested
}
func (s *Shell) validateInstanceName(value, excludeID string) (string, error) {
name, err := domain.NormalizeInstanceName(value)
if err != nil {
return "", errors.New("实例名称不能为空,且不能包含 Windows 非法字符、保留设备名或末尾句点。")
}
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
if strings.EqualFold(strings.TrimSpace(row.Name), name) {
return "", errors.New("实例名称已存在,请更换。")
}
}
return name, nil
}
func samePathText(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return left == right
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
func (s *Shell) allocateInstanceID() string {
@@ -2668,6 +2816,9 @@ func (s *Shell) pathPickButtonLabel(field PathField) string {
if s.executablePick.running && s.executablePick.field == field {
return "选择中…"
}
if s.directoryPick.running && ((field == PathDefaultUserData && s.directoryPick.target == directoryPickDefaultUserData) || (field == PathLogDirectory && s.directoryPick.target == directoryPickLogDirectory)) {
return "选择中…"
}
return "选择"
}
@@ -3442,16 +3593,75 @@ func (s *Shell) settingsState() (SettingsState, error) {
if err != nil {
return SettingsState{}, err
}
defaultDir, err := normalizeSettingsDirectory(s.dataDir.Text(), "默认 User Data Dir")
if err != nil {
return SettingsState{}, err
}
logDir, err := normalizeSettingsDirectory(s.logDir.Text(), "日志目录")
if err != nil {
return SettingsState{}, err
}
return SettingsState{
ChromePath: s.chromePath.Text(),
EdgePath: s.edgePath.Text(),
DefaultDir: s.dataDir.Text(),
LogDir: s.logDir.Text(),
DefaultDir: defaultDir,
DefaultDirMode: s.selectedDirectoryMode(defaultDir, s.portableDefaultDir, s.defaultDirMode),
LogDir: logDir,
LogDirMode: s.selectedDirectoryMode(logDir, s.portableLogDir, s.logDirMode),
RemoteDebugStartPort: port,
CloseOnExit: s.closeOnExit.Value,
}, nil
}
func normalizeSettingsDirectory(value, label string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || !filepath.IsAbs(value) {
return "", fmt.Errorf("%s必须是绝对路径。", label)
}
return filepath.Clean(value), nil
}
func normalizeDirectoryMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "portable") {
return "portable"
}
return "custom"
}
func (s *Shell) selectedDirectoryMode(value, portable, currentMode string) string {
if strings.EqualFold(currentMode, "portable") && samePathText(value, portable) {
return "portable"
}
return "custom"
}
func (s *Shell) directoryMode(field PathField) string {
if field == PathDefaultUserData {
return s.selectedDirectoryMode(s.dataDir.Text(), s.portableDefaultDir, s.defaultDirMode)
}
return s.selectedDirectoryMode(s.logDir.Text(), s.portableLogDir, s.logDirMode)
}
func (s *Shell) restorePortableDirectory(field PathField) {
if field == PathDefaultUserData {
if s.portableDefaultDir == "" {
s.setPathFieldFeedback(field, "无法确定程序目录中的默认 User Data Dir。")
return
}
s.dataDir.SetText(s.portableDefaultDir)
s.defaultDirMode = "portable"
s.setPathFieldFeedback(field, "已恢复为程序目录下的默认 User Data Dir。")
return
}
if s.portableLogDir == "" {
s.setPathFieldFeedback(field, "无法确定程序目录中的默认日志目录。")
return
}
s.logDir.SetText(s.portableLogDir)
s.logDirMode = "portable"
s.setPathFieldFeedback(field, "已恢复为程序目录下的默认日志目录。")
}
func normalizeRemoteDebugStartPort(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
@@ -3584,19 +3794,11 @@ func (s *Shell) chooseInstanceDirectory() {
func (s *Shell) chooseDirectory(target directoryPickTarget) {
if s.directoryPick.running {
if target == directoryPickEdit {
s.editFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
} else {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
}
s.setDirectoryPickFeedback(target, "目录选择器已打开,请在系统窗口中选择或取消。")
return
}
if s.directoryChooser == nil {
if target == directoryPickEdit {
s.editFeedback = "目录选择器尚未准备好。"
} else {
s.formFeedback = "目录选择器尚未准备好。"
}
s.setDirectoryPickFeedback(target, "目录选择器尚未准备好。")
return
}
s.directoryPick.request++
@@ -3605,11 +3807,7 @@ func (s *Shell) chooseDirectory(target directoryPickTarget) {
s.directoryPick.cancel = cancel
s.directoryPick.running = true
s.directoryPick.target = target
if target == directoryPickEdit {
s.editFeedback = "正在打开目录选择器…"
} else {
s.formFeedback = "正在打开目录选择器…"
}
s.setDirectoryPickFeedback(target, "正在打开目录选择器…")
go func() {
path, err := s.directoryChooser(ctx)
s.directoryResults <- directoryPickResult{request: request, path: path, err: err}
@@ -3631,22 +3829,30 @@ func (s *Shell) consumeDirectoryResults() {
s.directoryPick.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.setDirectoryPickFeedback(target, "已取消选择 User Data Dir。")
s.setDirectoryPickFeedback(target, "已取消选择目录。")
} else {
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择 User Data Dir 失败:%v", result.err))
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择目录失败:%v", result.err))
}
continue
}
if result.path == "" {
s.setDirectoryPickFeedback(target, "未选择 User Data Dir。")
s.setDirectoryPickFeedback(target, "未选择目录。")
continue
}
if target == directoryPickEdit {
switch target {
case directoryPickEdit:
s.editDir.SetText(result.path)
} else {
case directoryPickDefaultUserData:
s.dataDir.SetText(result.path)
s.defaultDirMode = "custom"
case directoryPickLogDirectory:
s.logDir.SetText(result.path)
s.logDirMode = "custom"
default:
s.instanceDir.SetText(result.path)
s.defaultCreateDirActive = false
}
s.setDirectoryPickFeedback(target, "已更新 User Data Dir。")
s.setDirectoryPickFeedback(target, "已更新目录。")
default:
return
}
@@ -3654,24 +3860,26 @@ func (s *Shell) consumeDirectoryResults() {
}
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
if target == directoryPickEdit {
switch target {
case directoryPickEdit:
s.editFeedback = message
return
case directoryPickDefaultUserData:
s.setPathFieldFeedback(PathDefaultUserData, message)
case directoryPickLogDirectory:
s.setPathFieldFeedback(PathLogDirectory, message)
default:
s.formFeedback = message
}
s.formFeedback = message
}
func (s *Shell) instanceDirButtonLabel() string {
if s.directoryPick.running {
if s.directoryPick.running && s.directoryPick.target == directoryPickCreate {
return "选择中…"
}
return "选择路径"
}
func (s *Shell) resetSettings() {
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.SetSettings(s.lastSavedSettings)
s.pathFeedback = "已恢复未保存前的设置。"
}
+49 -2
View File
@@ -618,6 +618,48 @@ func TestShellSettingsCanBeRestored(t *testing.T) {
}
}
func TestShellTracksPortableSettingsDirectoriesAndRestoresThem(t *testing.T) {
shell := NewShell(material.NewTheme())
root := t.TempDir()
portableData := filepath.Join(root, "user_data_dirs")
portableLogs := filepath.Join(root, "logs")
shell.SetPortableDirectoryDefaults(portableData, portableLogs)
shell.SetSettings(SettingsState{DefaultDir: portableData, DefaultDirMode: "portable", LogDir: portableLogs, LogDirMode: "portable", RemoteDebugStartPort: 9666, CloseOnExit: true})
shell.dataDir.SetText(filepath.Join(root, "custom"))
state, err := shell.settingsState()
if err != nil || state.DefaultDirMode != "custom" {
t.Fatalf("custom settings state = %#v, error = %v", state, err)
}
shell.restorePortableDirectory(PathDefaultUserData)
state, err = shell.settingsState()
if err != nil || state.DefaultDir != portableData || state.DefaultDirMode != "portable" {
t.Fatalf("restored settings state = %#v, error = %v", state, err)
}
}
func TestShellRejectsDuplicateInstanceNamesButAllowsEditingSelf(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetInstances([]InstanceRow{
{ID: "one", Name: "Audit", Browser: "Chrome", UserDataDir: `C:\profiles\one`, Status: "已退出", PreferredRemoteDebugPort: 9666},
{ID: "two", Name: "投放", Browser: "Edge", UserDataDir: `C:\profiles\two`, Status: "已退出", PreferredRemoteDebugPort: 9667},
})
shell.instanceName.SetText("AUDIT")
shell.instanceDir.SetText(`C:\profiles\three`)
shell.instancePort.SetText("9668")
shell.createInstanceFromForm()
if len(shell.rows) != 2 || !strings.Contains(shell.formFeedback, "实例名称") {
t.Fatalf("duplicate instance create = rows %#v, feedback %q", shell.rows, shell.formFeedback)
}
shell.beginEdit("one")
shell.editName.SetText("Audit")
shell.editURL.SetText("https://example.com")
shell.saveEdit()
row, _ := shell.instanceRow("one")
if row.TargetURL != "https://example.com" || shell.editingID != "" {
t.Fatalf("self edit = %#v, feedback %q", row, shell.editFeedback)
}
}
func TestShellNormalizesRemoteDebugStartPort(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.remoteDebugPort.SetText("")
@@ -1011,10 +1053,15 @@ func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) {
shell.beginCreate()
firstID := shell.pendingCreateID
firstDir := shell.instanceDir.Text()
if firstID == "" || firstDir != filepath.Join(root, firstID) {
if firstID == "" || filepath.Dir(firstDir) != root || !strings.HasPrefix(filepath.Base(firstDir), "instance-新实例-") {
t.Fatalf("first default directory = %q for id %q", firstDir, firstID)
}
shell.instanceName.SetText("第一个实例")
shell.syncDefaultCreateDirectory()
firstDir = shell.instanceDir.Text()
if !strings.HasPrefix(filepath.Base(firstDir), "instance-第一个实例-") {
t.Fatalf("named default directory = %q", firstDir)
}
shell.createInstanceFromForm()
created := shell.rows[len(shell.rows)-1]
if created.ID != firstID || created.UserDataDir != firstDir {
@@ -1024,7 +1071,7 @@ func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) {
shell.beginCreate()
secondID := shell.pendingCreateID
secondDir := shell.instanceDir.Text()
if secondID == "" || secondID == firstID || secondDir != filepath.Join(root, secondID) || secondDir == firstDir {
if secondID == "" || secondID == firstID || filepath.Dir(secondDir) != root || secondDir == firstDir {
t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir)
}
}