293 lines
9.6 KiB
Go
293 lines
9.6 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 or download shares (/Applications on
|
|
// a Mac; the home folder, Downloads, Desktop and Documents everywhere) is
|
|
// taken for magpie's own only when it already holds magpie's files and the
|
|
// installed folder doesn't, or with a ".portable" marker: a "data" folder
|
|
// there is as likely another app's or the user's own. Taking one showed a
|
|
// user who had never asked for portable an empty magpie (#989), and wrote
|
|
// magpie's files into a Downloads\data folder a Windows user's archiver
|
|
// empties, so a provider just added was gone (tangle778 on X).
|
|
//
|
|
// 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 shared folder and %s has magpie's files too; a .portable file beside magpie makes it portable", 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, data) {
|
|
return absolute(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Passed is the data folder beside a magpie run from exe that holds
|
|
// magpie's files but that Resolve doesn't take for its own, because it is
|
|
// in a shared folder while the installed folder has magpie's files too, or
|
|
// "". A data folder holding none of them is someone else's and isn't named.
|
|
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() && hasFiles(data) && Resolve(exe) == "" {
|
|
return absolute(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// shared says whether the data folder in dir can't be taken for magpie's:
|
|
// dir is a folder every app or download shares, where a folder named data
|
|
// is as likely another app's or the user's own, and either data holds none
|
|
// of magpie's files or the installed folder holds them too. One who keeps
|
|
// magpie portable there has its files in data and never ran it installed,
|
|
// and keeps it portable; a ".portable" marker beside it makes it portable
|
|
// whatever is there.
|
|
func shared(dir, data string) bool {
|
|
if !sharedFolder(dir) {
|
|
return false
|
|
}
|
|
if !hasFiles(data) {
|
|
return true
|
|
}
|
|
in := installed()
|
|
return in != "" && hasFiles(in)
|
|
}
|
|
|
|
// hasFiles says whether dir holds magpie's own files.
|
|
func hasFiles(dir string) bool {
|
|
for _, f := range []string{"providers.json", "logins.json", "settings.json"} {
|
|
if fi, err := os.Stat(filepath.Join(dir, f)); err == nil && !fi.IsDir() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sharedFolder says whether dir is a folder every app or download shares:
|
|
// /Applications and ~/Applications on a Mac, and the home folder, its
|
|
// Downloads, Desktop and Documents everywhere. Tests change it.
|
|
var sharedFolder = func(dir string) bool {
|
|
var dirs []string
|
|
if runtime.GOOS == "darwin" {
|
|
dirs = append(dirs, "/Applications")
|
|
}
|
|
if h, err := Home(); err == nil {
|
|
dirs = append(dirs, h)
|
|
for _, d := range []string{"Downloads", "Desktop", "Documents"} {
|
|
dirs = append(dirs, filepath.Join(h, d))
|
|
}
|
|
if runtime.GOOS == "darwin" {
|
|
dirs = append(dirs, filepath.Join(h, "Applications"))
|
|
}
|
|
}
|
|
for _, d := range dirs {
|
|
if strings.EqualFold(filepath.Clean(dir), filepath.Clean(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
|
|
}
|