Files
magpie/internal/appdir/appdir.go
T
yetone e520849b74 appdir: a folder named data in /Applications isn't taken for magpie's portable data while ~/.config/magpie has its files (#989, Treyang: after updating 0.1.1077 → 0.1.1078 magpie opened with no providers, sign-ins or settings; ~/.config/magpie was intact and /Applications/data had appeared) — portable mode (#508) took any folder named data beside the folder holding Magpie.app, and on a Mac that folder is /Applications, which every app shares. The reporter's /Applications/data began with nodes/, providers/, tools/, skills/ and an initialized marker, none of which magpie makes (magpie keeps providers.json, not a providers folder); magpie's cli-identity.json, install-id and last-version came after, written once a magpie had taken the folder for its own. The updater didn't make it: Stage only unpacks and codesign-checks the new app in .magpie-update, Install renames, Relaunch opens the bundle, and nothing runs the staged copy; appdir never creates a data folder without a .portable marker either. So a folder another app left there turned an installed magpie portable and empty at its next start. Now, in /Applications or ~/Applications, a data folder is magpie's portable data only while the installed folder (~/.config/magpie, or $XDG_CONFIG_HOME/magpie) holds no providers.json, logins.json or settings.json — one who keeps magpie portable there never ran it installed, and keeps it — or with a .portable marker beside the app, which makes it portable as before. A data folder beside magpie anywhere else (a USB stick, a Windows or Linux folder of its own, an AppImage's) is taken as before. A user already hit is back on ~/.config/magpie at the next start, and the data folder is left as it is, never deleted or moved; the log says it was passed over. TestResolveSharedFolder (/Applications stood in by a temp folder: a data folder with nodes/ and install-id, installed state present/absent, .portable, a data folder on a folder of its own) fails without the change (Resolve and Config give the shared data folder) and passes; TestSharedFolderIsApplications pins which folders count. go test -race ./internal/appdir/ (-count=3), go test ./internal/settings/ ./internal/shortcut/ ./internal/update/, go vet (darwin, windows), darwin/linux(nogui)/windows builds pass. Not tried with the reporter's /Applications or the updater on a real install.
2026-10-06 14:48:39 +08:00

275 lines
8.7 KiB
Go

// Package appdir decides where magpie keeps its own files: the one place
// every other package asks (#508).
//
// Installed, that is ~/.config/magpie ($XDG_CONFIG_HOME/magpie) for what
// magpie keeps and ~/.cache/magpie ($XDG_CACHE_HOME/magpie) for what it
// can fetch again, as it always was. Portable, when a folder named "data"
// (or a file named ".portable") sits beside magpie, everything magpie
// keeps goes into that data folder, its caches into data/cache and the
// Windows webview's profile into data/webview2 (no Start-menu shortcut or
// App Paths entry is made either), and
// nothing of magpie's own is written to the user's profile, the way VS
// Code's portable mode works. "Beside magpie" is the folder holding the
// executable, with links followed; for a Mac app it is the folder holding
// Magpie.app (a file put inside the bundle would break its signature), and
// for an AppImage the folder holding the .AppImage file, not the folder it
// is mounted at while it runs.
//
// What isn't magpie's own stays where its owner reads it: the agents'
// configs (~/.claude, ~/.codex…), the sign-ins they keep in the system
// keychain, the folders the OS reads for autostart and link handling, and
// temporary folders.
//
// A data folder in a folder every app shares (/Applications on a Mac) is
// not taken for magpie's own while magpie's installed folder holds its
// files (#989): a "data" folder there is as likely another app's, and
// taking it showed a user who had never asked for portable an empty magpie.
//
// The decision is made once, from the executable as it was when magpie
// started: an update moving the running copy aside (into .magpie-update on
// a Mac, to magpie.exe.old on Windows) leaves it running from somewhere
// else, and its files must not move with it.
package appdir
import (
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
const (
// DataName is the folder beside magpie that makes it portable and
// holds its files.
DataName = "data"
// MarkerName is a file beside magpie that makes it portable too, its
// files then going into a data folder made beside it.
MarkerName = ".portable"
)
var (
once sync.Once
portable string // the data folder, or "" when installed
)
// Portable is the data folder magpie keeps everything in, or "" when it
// isn't portable.
func Portable() string {
once.Do(func() {
exe, err := os.Executable()
if err != nil {
return
}
portable = Resolve(exe)
if portable == "" {
if data := Passed(exe); data != "" {
log.Printf("appdir: %s is beside magpie but not taken for its portable data: it is in a folder every app shares, and %s has magpie's files (#989)", data, installed())
}
}
})
return portable
}
// UseExecutable decides again as if magpie ran from exe ("" for an
// installed magpie, whatever runs): for tests.
func UseExecutable(exe string) {
once.Do(func() {})
portable = ""
if exe != "" {
portable = Resolve(exe)
}
}
// Resolve is the data folder of a magpie run from exe, or "" when it isn't
// portable.
func Resolve(exe string) string {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
base := Beside(exe)
data := filepath.Join(base, DataName)
if fi, err := os.Stat(filepath.Join(base, MarkerName)); err == nil && !fi.IsDir() {
return absolute(data)
}
if fi, err := os.Stat(data); err == nil && fi.IsDir() && !shared(base) {
return absolute(data)
}
return ""
}
// Passed is the data folder beside a magpie run from exe that Resolve
// doesn't take for its own because it is in a folder every app shares
// while the installed folder has magpie's files, or "".
func Passed(exe string) string {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
base := Beside(exe)
data := filepath.Join(base, DataName)
if fi, err := os.Stat(data); err == nil && fi.IsDir() && Resolve(exe) == "" {
return absolute(data)
}
return ""
}
// shared says whether a data folder in dir can't be taken for magpie's:
// dir is a folder every app shares (/Applications or ~/Applications on a
// Mac), where a folder named data is as likely another app's, and the
// installed folder holds magpie's files. One who keeps magpie portable
// there never ran it installed, and keeps it portable; a ".portable"
// marker beside it makes it portable whatever is installed.
func shared(dir string) bool {
if !sharedFolder(dir) {
return false
}
in := installed()
if in == "" {
return false
}
for _, f := range []string{"providers.json", "logins.json", "settings.json"} {
if fi, err := os.Stat(filepath.Join(in, f)); err == nil && !fi.IsDir() {
return true
}
}
return false
}
// sharedFolder says whether dir is a folder every app shares; tests change
// it.
var sharedFolder = func(dir string) bool {
if runtime.GOOS != "darwin" {
return false
}
dirs := []string{"/Applications"}
if h, err := Home(); err == nil {
dirs = append(dirs, filepath.Join(h, "Applications"))
}
for _, d := range dirs {
if strings.EqualFold(filepath.Clean(dir), d) {
return true
}
}
return false
}
// installed is the folder an installed magpie keeps its files in, or ""
// with no home folder.
func installed() string {
if x := os.Getenv("XDG_CONFIG_HOME"); rooted(x) {
return filepath.Join(x, "magpie")
}
h, err := Home()
if err != nil {
return ""
}
return filepath.Join(h, ".config", "magpie")
}
// Beside is the folder a portable magpie run from exe keeps its data folder
// in: the executable's own, the one holding the .app on a Mac, the one
// holding the .AppImage on Linux. APPIMAGE counts only for the AppImage
// magpie runs from (exe under its mount, APPDIR): every program an
// AppImage starts inherits both, a magpie started from an AppImage
// terminal too.
func Beside(exe string) string {
if img := os.Getenv("APPIMAGE"); img != "" && runtime.GOOS == "linux" && within(exe, os.Getenv("APPDIR")) {
return filepath.Dir(img)
}
dir := filepath.Dir(exe)
// …/Magpie.app/Contents/MacOS/magpie
if filepath.Base(dir) == "MacOS" && filepath.Base(filepath.Dir(dir)) == "Contents" {
if app := filepath.Dir(filepath.Dir(dir)); strings.EqualFold(filepath.Ext(app), ".app") {
return filepath.Dir(app)
}
}
return dir
}
// within says whether path lies in the absolute folder dir.
func within(path, dir string) bool {
if !filepath.IsAbs(dir) {
return false
}
rel, err := filepath.Rel(dir, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func absolute(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
}
return p
}
// Config is the folder magpie keeps its settings, providers, usage and the
// rest of its own state in.
func Config() string {
if p := Portable(); p != "" {
return p
}
if x := os.Getenv("XDG_CONFIG_HOME"); rooted(x) {
return filepath.Join(x, "magpie")
}
return filepath.Join(mustHome(), ".config", "magpie")
}
// Redirected is the folder magpie keeps its files in when it is opened
// from the Dock, the Start menu or a desktop launcher (~/.config/magpie),
// while this magpie reads them from another that XDG_CONFIG_HOME, set in
// the shell it was run from, names; "" when they are the same, or magpie
// is portable. A magpie run in such a shell sees none of what the app's
// window shows.
func Redirected() string {
if Portable() != "" || os.Getenv("XDG_CONFIG_HOME") == "" {
return ""
}
h, err := Home()
if err != nil {
return ""
}
if def := filepath.Join(h, ".config", "magpie"); filepath.Clean(def) != filepath.Clean(Config()) {
return def
}
return ""
}
// Cache is the folder for what magpie can fetch again (the models.dev
// catalog, exchange rates, market lists).
func Cache() string {
if p := Portable(); p != "" {
return filepath.Join(p, "cache")
}
if x := os.Getenv("XDG_CACHE_HOME"); rooted(x) {
return filepath.Join(x, "magpie")
}
return filepath.Join(mustHome(), ".cache", "magpie")
}
// WebView is the folder the Windows webview (WebView2) keeps its profile
// in — its cache, cookies and the pages' storage — or "" for its own
// default. Installed, that default is %APPDATA%\<exe name>, as it always
// was, so nothing kept there is lost; portable, it is data\webview2, so a
// portable magpie leaves no magpie.exe folder in the user's AppData (#508).
func WebView() string {
if p := Portable(); p != "" {
return filepath.Join(p, "webview2")
}
return ""
}
// SystemCache is the cache folder the OS names for magpie
// (~/Library/Caches/magpie on a Mac, %LocalAppData%\magpie on Windows),
// which a few caches have always used; portable, it is Cache.
func SystemCache() (string, error) {
if p := Portable(); p != "" {
return filepath.Join(p, "cache"), nil
}
d, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(d, "magpie"), nil
}