275 lines
8.7 KiB
Go
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
|
|
}
|