//go:build windows package tui import ( "errors" "fmt" "os" "path/filepath" "strings" "gitgud.io/mike/mpv-manager/internal/fileops" "golang.org/x/sys/windows/registry" ) const userEnvironmentKey = `Environment` type registryPATHSnapshot struct { exists bool value string valueType uint32 } func currentManagerPATHInfo() (managerPATHInfo, error) { homeDir, err := os.UserHomeDir() if err != nil { return managerPATHInfo{}, fmt.Errorf("resolve home directory: %w", err) } binDir := filepath.Join(homeDir, "bin") return managerPATHInfo{ BinDir: binDir, BinaryPath: filepath.Join(binDir, "mpv-manager.exe"), AliasPath: filepath.Join(binDir, "mpv-install.exe"), }, nil } func prepareManagerPATHInstall(info managerPATHInfo, source string, emit func(string)) (reversiblePATHMutation, error) { sourceInfo, err := os.Stat(source) if err != nil { return reversiblePATHMutation{}, fmt.Errorf("inspect source executable: %w", err) } if !sourceInfo.Mode().IsRegular() { return reversiblePATHMutation{}, fmt.Errorf("source executable is not a regular file: %s", source) } binaryData, err := os.ReadFile(source) if err != nil { return reversiblePATHMutation{}, fmt.Errorf("read source executable: %w", err) } snapshots, err := capturePaths(info.BinaryPath, info.AliasPath) if err != nil { return reversiblePATHMutation{}, err } registrySnapshot, err := readUserPATHSnapshot() if err != nil { return reversiblePATHMutation{}, err } rollback := func() error { return errors.Join(rollbackPaths(snapshots...), restoreUserPATH(registrySnapshot)) } fail := func(operationErr error) (reversiblePATHMutation, error) { return reversiblePATHMutation{}, errors.Join(operationErr, wrapRollbackError(rollback())) } if err := fileops.AtomicWrite(info.BinaryPath, binaryData, 0o755); err != nil { return fail(fmt.Errorf("install command binary: %w", err)) } if err := fileops.AtomicWrite(info.AliasPath, binaryData, 0o755); err != nil { return fail(fmt.Errorf("install mpv-install command alias: %w", err)) } if err := updateUserPATH(info.BinDir, true); err != nil { return fail(err) } emit(fmt.Sprintf("Installed executable: %s", info.BinaryPath)) emit(fmt.Sprintf("Installed command alias: %s", info.AliasPath)) emit("Updated the current-user Windows PATH") return reversiblePATHMutation{info: info, rollback: rollback}, nil } func prepareManagerPATHRemoval(info managerPATHInfo, emit func(string)) (reversiblePATHMutation, error) { snapshots, err := capturePaths(info.BinaryPath, info.AliasPath) if err != nil { return reversiblePATHMutation{}, err } registrySnapshot, err := readUserPATHSnapshot() if err != nil { return reversiblePATHMutation{}, err } rollback := func() error { return errors.Join(rollbackPaths(snapshots...), restoreUserPATH(registrySnapshot)) } fail := func(operationErr error) (reversiblePATHMutation, error) { return reversiblePATHMutation{}, errors.Join(operationErr, wrapRollbackError(rollback())) } for _, path := range []string{info.BinaryPath, info.AliasPath} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fail(fmt.Errorf("remove %s: %w", path, err)) } } if err := updateUserPATH(info.BinDir, false); err != nil { return fail(err) } emit(fmt.Sprintf("Removed executable: %s", info.BinaryPath)) emit(fmt.Sprintf("Removed command alias: %s", info.AliasPath)) emit("Removed the command directory from the current-user Windows PATH") return reversiblePATHMutation{info: info, rollback: rollback}, nil } func managerPATHIsRegistered(info managerPATHInfo) bool { binaryInfo, err := os.Lstat(info.BinaryPath) if err != nil || !binaryInfo.Mode().IsRegular() { return false } snapshot, err := readUserPATHSnapshot() return err == nil && snapshot.exists && pathListContains(snapshot.value, info.BinDir) } func readUserPATHSnapshot() (registryPATHSnapshot, error) { key, err := registry.OpenKey(registry.CURRENT_USER, userEnvironmentKey, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { return registryPATHSnapshot{}, nil } return registryPATHSnapshot{}, fmt.Errorf("open current-user environment: %w", err) } defer key.Close() value, valueType, err := key.GetStringValue("Path") if err != nil { if errors.Is(err, registry.ErrNotExist) { return registryPATHSnapshot{}, nil } return registryPATHSnapshot{}, fmt.Errorf("read current-user PATH: %w", err) } return registryPATHSnapshot{exists: true, value: value, valueType: valueType}, nil } func updateUserPATH(binDir string, add bool) error { snapshot, err := readUserPATHSnapshot() if err != nil { return err } parts := splitWindowsPATH(snapshot.value) filtered := make([]string, 0, len(parts)+1) for _, part := range parts { if sameWindowsPath(part, binDir) { continue } filtered = append(filtered, part) } if add { filtered = append(filtered, binDir) } return writeUserPATH(strings.Join(filtered, ";"), snapshot.valueType) } func restoreUserPATH(snapshot registryPATHSnapshot) error { key, _, err := registry.CreateKey(registry.CURRENT_USER, userEnvironmentKey, registry.QUERY_VALUE|registry.SET_VALUE) if err != nil { return fmt.Errorf("open current-user environment for rollback: %w", err) } defer key.Close() if !snapshot.exists { if err := key.DeleteValue("Path"); err != nil && !errors.Is(err, registry.ErrNotExist) { return fmt.Errorf("remove current-user PATH during rollback: %w", err) } return nil } return setRegistryString(key, snapshot.value, snapshot.valueType) } func writeUserPATH(value string, valueType uint32) error { key, _, err := registry.CreateKey(registry.CURRENT_USER, userEnvironmentKey, registry.QUERY_VALUE|registry.SET_VALUE) if err != nil { return fmt.Errorf("open current-user environment: %w", err) } defer key.Close() if err := setRegistryString(key, value, valueType); err != nil { return fmt.Errorf("write current-user PATH: %w", err) } return nil } func setRegistryString(key registry.Key, value string, valueType uint32) error { if valueType == registry.EXPAND_SZ { return key.SetExpandStringValue("Path", value) } return key.SetStringValue("Path", value) } func splitWindowsPATH(value string) []string { raw := strings.Split(value, ";") parts := make([]string, 0, len(raw)) for _, part := range raw { part = strings.TrimSpace(part) if part != "" { parts = append(parts, part) } } return parts } func pathListContains(value, target string) bool { for _, part := range splitWindowsPATH(value) { if sameWindowsPath(part, target) { return true } } return false } func sameWindowsPath(left, right string) bool { left = strings.Trim(strings.TrimSpace(left), `"`) right = strings.Trim(strings.TrimSpace(right), `"`) return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) }