github.com/haraldrudell/parl@v0.4.176/pos/appdir.go (about)

     1  /*
     2  © 2021–present Harald Rudell <harald.rudell@gmail.com> (https://haraldrudell.github.io/haraldrudell/)
     3  ISC License
     4  */
     5  
     6  package pos
     7  
     8  import (
     9  	"os"
    10  	"path/filepath"
    11  	"sync/atomic"
    12  	"unicode"
    13  
    14  	"github.com/haraldrudell/parl/perrors"
    15  	"github.com/haraldrudell/parl/pfs"
    16  	"github.com/haraldrudell/parl/punix"
    17  )
    18  
    19  const (
    20  	// path segment “.local”.
    21  	// “~/.local/share” is a standardized directory on Linux
    22  	dotLocalDir = ".local"
    23  	// path segement “share”.
    24  	// “~/.local/share” is a standardized directory on Linux
    25  	shareDir = "share"
    26  	// mode for created directories
    27  	urwx os.FileMode = 0700
    28  )
    29  
    30  // for testing
    31  var homeDirHook string
    32  
    33  // AppDirectory manages a per-user writable app-specific directory
    34  type AppDirectory struct {
    35  	// app name like “myapp”
    36  	App string
    37  	// absolute clean symlink-free path if app-directory exists
    38  	//	- macOS: “/Users/user/.local/share/myapp”
    39  	//	- Linux: “/home/user/.local/share/myapp”
    40  	abs atomic.Pointer[string]
    41  }
    42  
    43  // NewAppDir returns a writable directory object in the user’s home directory
    44  //   - appName: application name like “myapp”
    45  //     Unicode letters and digits
    46  //   - directory is “~/.local/share/[appName]”
    47  //   - parent directory is based on the running process’ owner
    48  //   - does not rely on environment variables
    49  //
    50  // Usage:
    51  //
    52  //	var appDir = NewAppDir("myapp")
    53  //	if err = appDir.EnsureDir(); err != nil {…
    54  //	var knownToExistAbsCleanNoSymlinksNeverErrors = appDir.Directory()
    55  func NewAppDir(appName string) (appd *AppDirectory) { return &AppDirectory{App: appName} }
    56  
    57  // best-effort single-value absolute clean possibly symlink-free directory
    58  //   - returns an absolute path whether the directory exists or not
    59  //   - if directory exists, absolute clean symlink-free, otherwise absolute clean
    60  //   - Directory may panic from errors that are returned by [AppDirectory.EnsureDir] or
    61  //     [AppDirectory.Path].
    62  //     To avoid panics, invoke those methods first.
    63  //
    64  // Usage:
    65  //
    66  //	var dir = NewAppDir("myapp").Directory()
    67  func (d *AppDirectory) Directory() (abs string) {
    68  	var isNotExist bool
    69  	var err error
    70  	if abs, isNotExist, err = d.Path(); err != nil && !isNotExist {
    71  		panic(err) // some error
    72  	}
    73  	return
    74  }
    75  
    76  // EnsureDir ensures the directory exists
    77  func (d *AppDirectory) EnsureDir() (err error) {
    78  
    79  	// get path while checking if already exists
    80  	var abs string
    81  	var isNotExist bool
    82  	if abs, isNotExist, err = d.Path(); err == nil {
    83  		return // directory already exists return
    84  	} else if !isNotExist {
    85  		return // some error
    86  	}
    87  
    88  	// MkDirAll begins with stat to see if path exists
    89  	if err = os.MkdirAll(abs, urwx); perrors.IsPF(&err, "os.MkdirAll: %w", err) {
    90  		return
    91  	}
    92  	// update d.abs
    93  	_, _, err = d.eval(abs)
    94  
    95  	return
    96  }
    97  
    98  // Path returns best-effort absolute clean path
    99  //   - if the app-directory exists, abs is also symlink-free
   100  //   - outcomes:
   101  //   - — err: nil: abs is absolute clean symlink-free, app directory exists
   102  //   - — isNotExist: true, err: non-nil: app directory does not eixst.
   103  //     abs is absolute clean.
   104  //     err is errno ENOENT
   105  //   - — err: non-nil, isNotExist: false: some error
   106  //   - —
   107  //   - macOS: “/Users/user/.local/share/myapp”
   108  //   - Linux: “/home/user/.local/share/myapp”
   109  //   - note: symlinks can only be evaled if a path exists
   110  func (d *AppDirectory) Path() (abs string, isNotExist bool, err error) {
   111  
   112  	// if already present
   113  	if ap := d.abs.Load(); ap != nil {
   114  		abs = *ap
   115  		return // success: already has abs, directory exists return
   116  	}
   117  
   118  	// check appName
   119  	var appName string
   120  	if appName, err = d.checkAppName(); err != nil {
   121  		return // bad appName return
   122  	}
   123  
   124  	// get user’s home directory
   125  	var homeDir string
   126  	if h := homeDirHook; h == "" {
   127  		if homeDir, err = UserHome(); err != nil {
   128  			return // failure to obtain home directory return
   129  		}
   130  	} else {
   131  		homeDir = h
   132  	}
   133  
   134  	// get app directory’s parent
   135  	//	- absolute, maybe unclean, maybe symlinks
   136  	var parentDir = filepath.Join(homeDir, dotLocalDir, shareDir)
   137  
   138  	// get app directory
   139  	//	- absolute, maybe unclean, maybe symlinks
   140  	var a = filepath.Join(parentDir, appName)
   141  
   142  	// try to unsymlink app directory
   143  	if abs, isNotExist, err = d.eval(a); err == nil {
   144  		return // app directory exists success return
   145  	} else if !isNotExist {
   146  		return // some error return
   147  	}
   148  	// err is non-nil, isNotExist true
   149  
   150  	// try to unsymlink parent directory
   151  	if p, e := pfs.AbsEval(parentDir); e != nil {
   152  		if punix.IsENOENT(e) {
   153  			abs = a
   154  			return // parent no exist either, return isNotExist result
   155  		}
   156  		err = e // some new error
   157  		isNotExist = false
   158  		return // return error from parent directory
   159  	} else {
   160  		// use th evealed parent directory
   161  		abs = filepath.Clean(filepath.Join(p, appName))
   162  	}
   163  
   164  	return // parent exists, app dir does not isNotExist return
   165  }
   166  
   167  // checks that appName is usable
   168  func (d *AppDirectory) checkAppName() (appName string, err error) {
   169  
   170  	if appName = d.App; appName == "" {
   171  		err = perrors.NewPF("appName cannot be empty")
   172  		return // empty error return
   173  	}
   174  
   175  	for i, c := range appName {
   176  		if !unicode.IsDigit(c) && !unicode.IsLetter(c) {
   177  			err = perrors.ErrorfPF(
   178  				"appName can only contain Unicode letters or digits: #%d: %q",
   179  				i, c,
   180  			)
   181  			return // bad character error return
   182  		}
   183  	}
   184  
   185  	return // good return
   186  }
   187  
   188  // eval evaluates the full app directory path
   189  //   - on success, updates d.abs
   190  func (d *AppDirectory) eval(path string) (abs string, isNotExist bool, err error) {
   191  	var a string
   192  
   193  	if a, err = pfs.AbsEval(path); err != nil {
   194  		isNotExist = punix.IsENOENT(err)
   195  		return // some error including does not exist
   196  	}
   197  
   198  	// success, app directory exists and is evaled
   199  	d.abs.CompareAndSwap(nil, &a)
   200  	abs = a
   201  
   202  	return // success, directory exists
   203  }