Skip to content

Commit

Permalink
Implement persist dirs
Browse files Browse the repository at this point in the history
  • Loading branch information
Bios-Marcel committed Apr 15, 2024
1 parent ff522ca commit 62346b2
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 17 deletions.
9 changes: 7 additions & 2 deletions cmd/spoon/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/Bios-Marcel/spoon/internal/windows"
"github.com/Bios-Marcel/spoon/pkg/scoop"
wapi "github.com/iamacarpet/go-win64api"
"github.com/iamacarpet/go-win64api/shared"
Expand Down Expand Up @@ -61,11 +62,15 @@ func uninstallCmd() *cobra.Command {
return fmt.Errorf("error loading app details: %w", err)
}

// FIXME This currently only uninstalls a specific version. We
// need multiple versions current, specific all?
// FIXME This uninstalls the current version and then deletes
// all installed versions via file-deletion. Should this be part
// of the API and do we need to be more careful here?
if err := defaultScoop.Uninstall(app, app.Architecture); err != nil {
return fmt.Errorf("error uninstalling '%s': %w", arg, err)
}
if err := windows.ForceRemoveAll(filepath.Join(defaultScoop.AppDir(), app.Name)); err != nil {
return fmt.Errorf("error cleaning up installation of '%s': %w", arg, err)
}
}

// redirectedFlags, err := getFlags(cmd, "global", "purge")
Expand Down
2 changes: 2 additions & 0 deletions internal/windows/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
)

func Test_ParsePath(t *testing.T) {
t.Parallel()

path := windows.ParsePath(`C:\path_a;"C:\path_b";"C:\path_;";C:\path_c`)
require.Equal(t, []string{`C:\path_a`, `C:\path_b`, `C:\path_;`, `C:\path_c`}, []string(path))
require.Equal(t, `"C:\path_a";"C:\path_b";"C:\path_;";"C:\path_c"`, path.String())
Expand Down
51 changes: 51 additions & 0 deletions internal/windows/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,62 @@ func ExtractDir(dirToExtract, destinationDir string) error {
})
}

// ForceRemoveAll will delete any file or folder recursively. This will not
// delete the content of junctions.
func ForceRemoveAll(path string) error {
if err := os.Remove(path); err == nil || os.IsNotExist(err) {
return nil
}

if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("error marking path read-only")
}

info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("error stating file for deletion: %w", err)
}

if !info.IsDir() {
// Try to delete again, now that it is marked read-only
return os.Remove(path)
}

// This also resolves junctions.
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return fmt.Errorf("error resolving links: %w", err)
}

// Workaround for junctions, as they can be deleted directly. Can we use the
// stat call to identify the junction?
if resolved != path {
if err := os.Remove(path); err == nil || os.IsNotExist(err) {
return nil
}
}

files, err := GetDirFilenames(path)
if err != nil {
return fmt.Errorf("error reading dir names: %w", err)
}

for _, file := range files {
if err := ForceRemoveAll(filepath.Join(path, file)); err != nil {
return err
}
}

// Remove empty dir that's leftover.
return os.Remove(path)
}

func GetDirFilenames(dir string) ([]string, error) {
dirHandle, err := os.Open(dir)
if err != nil {
return nil, err
}
defer dirHandle.Close()
return dirHandle.Readdirnames(-1)
}

Expand Down
21 changes: 21 additions & 0 deletions pkg/scoop/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
DetailFieldDepends = "depends"
DetailFieldEnvSet = "env_set"
DetailFieldEnvAddPath = "env_add_path"
DetailFieldPersist = "persist"
DetailFieldExtractDir = "extract_dir"
DetailFieldExtractTo = "extract_to"
DetailFieldPostInstall = "post_install"
Expand All @@ -48,6 +49,7 @@ var DetailFieldsAll = []string{
DetailFieldDepends,
DetailFieldEnvSet,
DetailFieldEnvAddPath,
DetailFieldPersist,
DetailFieldExtractDir,
DetailFieldExtractTo,
DetailFieldPostInstall,
Expand Down Expand Up @@ -192,6 +194,25 @@ func (a *App) loadDetailFromManifestWithIter(
for key := iter.ReadObject(); key != ""; key = iter.ReadObject() {
a.EnvSet = append(a.EnvSet, EnvVar{Key: key, Value: iter.ReadString()})
}
case DetailFieldPersist:
if iter.WhatIsNext() == jsoniter.ArrayValue {
for iter.ReadArray() {
if iter.WhatIsNext() == jsoniter.ArrayValue {
_ = iter.ReadArray()
dir := PersistDir{Dir: iter.ReadString()}
// I am unsure whether there could be an array with a
// single value, so we treat it anyway.
if iter.ReadArray() {
dir.LinkName = iter.ReadString()
}
a.Persist = append(a.Persist, dir)
} else {
a.Persist = append(a.Persist, PersistDir{Dir: iter.ReadString()})
}
}
} else {
a.Persist = append(a.Persist, PersistDir{Dir: iter.ReadString()})
}
case DetailFieldInstaller:
installer := parseInstaller(iter)
a.Installer = &installer
Expand Down
94 changes: 79 additions & 15 deletions pkg/scoop/scoop.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func (b *Bucket) FindApp(name string) *App {
manifestPath: potentialManifest,
}
}

return nil
}

Expand Down Expand Up @@ -152,6 +153,7 @@ func (scoop *Scoop) findInstalledApp(iter *jsoniter.Iterator, name string) (*Ins
}
return nil, fmt.Errorf("error reading install.json: %w", err)
}
defer installJson.Close()

iter.Reset(installJson)

Expand Down Expand Up @@ -283,12 +285,13 @@ type App struct {
Version string `json:"version"`
Notes string `json:"notes"`

Bin []Bin `json:"bin"`
Shortcuts []Bin `json:"shortcuts"`
EnvAddPath []string `json:"env_add_path"`
EnvSet []EnvVar `json:"env_set"`
Bin []Bin `json:"bin"`
Shortcuts []Bin `json:"shortcuts"`
EnvAddPath []string `json:"env_add_path"`
EnvSet []EnvVar `json:"env_set"`
Persist []PersistDir `json:"persist"`

Downloadables []Downloadable
Downloadables []Downloadable `json:"downloadables"`

Depends []Dependency `json:"depends"`
Architecture map[ArchitectureKey]*Architecture `json:"architecture"`
Expand Down Expand Up @@ -334,6 +337,15 @@ type Dependency struct {
Name string
}

// PersistDir represents a directory in the installation of the application,
// which the app or the user will write to. This is placed in a separate
// location and kept upon uninstallation.
type PersistDir struct {
Dir string
// LinkName is optional and can be used to rename the [Dir].
LinkName string
}

type Bin struct {
Name string
Alias string
Expand Down Expand Up @@ -829,6 +841,7 @@ func validateHash(path, hashVal string) error {
if err != nil {
return fmt.Errorf("error determining checksum: %w", err)
}
defer file.Close()

if _, err := io.Copy(algo, file); err != nil {
return fmt.Errorf("error determining checksum: %w", err)
Expand Down Expand Up @@ -888,14 +901,9 @@ func (scoop *Scoop) Uninstall(app *InstalledApp, arch ArchitectureKey) error {
appDir := filepath.Join(scoop.AppDir(), app.Name)
currentDir := filepath.Join(appDir, "current")

// Make sure installation dir isn't readonly anymore. Scoop does this for
// some reason.
// FIXME The files inside are writable anyway. Should figure out why.
if err := os.Chmod(currentDir, 0o600); err != nil {
return fmt.Errorf("error making current dir deletable: %w", err)
}

if err := os.RemoveAll(currentDir); err != nil {
// The install dir is marked as read-only, but not the files inside.
// This will also unlink any persist-dir.
if err := windows.ForceRemoveAll(currentDir); err != nil {
return fmt.Errorf("error deleting installation files: %w", err)
}

Expand All @@ -904,7 +912,7 @@ func (scoop *Scoop) Uninstall(app *InstalledApp, arch ArchitectureKey) error {
}

// FIXME Do rest of the uninstall here
// 2. Remove shortcuts
// 1. Remove shortcuts

if err := scoop.runScript(resolvedApp.PostUninstall); err != nil {
return fmt.Errorf("error executing post_uninstall script: %w", err)
Expand Down Expand Up @@ -1030,7 +1038,7 @@ func (scoop *Scoop) install(iter *jsoniter.Iterator, appName string, arch Archit

versionDir := filepath.Join(appDir, app.Version)
if err := os.MkdirAll(versionDir, os.ModeDir); err != nil {
return fmt.Errorf("error creating isntallation targer dir: %w", err)
return fmt.Errorf("error creating installation target dir: %w", err)
}

cacheDir := scoop.CacheDir()
Expand Down Expand Up @@ -1120,6 +1128,62 @@ func (scoop *Scoop) install(iter *jsoniter.Iterator, appName string, arch Archit
return fmt.Errorf("error writing installation information: %w", err)
}

persistDir := filepath.Join(scoop.PersistDir(), app.Name)
for _, entry := range resolvedApp.Persist {
// While I did find one manifest with $dir in it, said manifest installs
// in a faulty way. (See versions/lynx283). The manifest hasn't really
// been touched for at least 5 years. Either this was a scoop feature at
// some point or it never was and went uncaught.

source := filepath.Join(versionDir, entry.Dir)
var target string
if entry.LinkName != "" {
target = filepath.Join(persistDir, entry.LinkName)
} else {
target = filepath.Join(persistDir, entry.Dir)
}

_, targetErr := os.Stat(target)
if targetErr != nil && !os.IsNotExist(targetErr) {
return targetErr
}
_, sourceErr := os.Stat(source)
if sourceErr != nil && !os.IsNotExist(sourceErr) {
return sourceErr
}

// Target exists
if targetErr == nil {
if sourceErr == nil {
// "Backup" the source. Scoop did this as well.
if err := os.Rename(source, source+".original"); err != nil {
return fmt.Errorf("error backing up source: %w", err)
}
}
} else if sourceErr == nil {
if err := os.Rename(source, target); err != nil {
return fmt.Errorf("error moving source to target: %w", err)
}
} else {
if err := os.MkdirAll(target, os.ModeDir); err != nil {
return fmt.Errorf("error creating target: %w", err)
}
}

targetInfo, err := os.Stat(target)
if err != nil {
return err
}

if targetInfo.IsDir() {
err = windows.CreateJunctions([2]string{target, source})
} else {
err = os.Link(target, source)
}
if err != nil {
return fmt.Errorf("error linking to persist target: %w", err)
}
}
if err := scoop.runScript(resolvedApp.PostInstall); err != nil {
return fmt.Errorf("error running post install script: %w", err)
}
Expand Down
20 changes: 20 additions & 0 deletions pkg/scoop/scoop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
)

func app(t *testing.T, name string) *scoop.App {
t.Helper()

defaultScoop, err := scoop.NewScoop()
require.NoError(t, err)

Expand All @@ -18,28 +20,38 @@ func app(t *testing.T, name string) *scoop.App {
}

func Test_ManifestForVersion(t *testing.T) {
t.Parallel()

defaultScoop, err := scoop.NewScoop()
require.NoError(t, err)

app, err := defaultScoop.FindAvailableApp("main/go")
require.NoError(t, err)

t.Run("found", func(t *testing.T) {
t.Parallel()

manifest, err := app.ManifestForVersion("1.22.0")
require.NoError(t, err)

// FIXME Read and validate.
require.NotNil(t, manifest)
})
t.Run("not found", func(t *testing.T) {
t.Parallel()

manifest, err := app.ManifestForVersion("1.69.420")
require.NoError(t, err)
require.Nil(t, manifest)
})
}

func Test_ParseBin(t *testing.T) {
t.Parallel()

t.Run("single string (single path entry)", func(t *testing.T) {
t.Parallel()

app := app(t, "main/ripgrep")

err := app.LoadDetails(scoop.DetailFieldBin)
Expand All @@ -49,6 +61,8 @@ func Test_ParseBin(t *testing.T) {
require.Equal(t, app.Bin[0], scoop.Bin{Name: "rg.exe"})
})
t.Run("top level array (path entries)", func(t *testing.T) {
t.Parallel()

app := app(t, "main/go")

err := app.LoadDetails(scoop.DetailFieldBin)
Expand All @@ -60,6 +74,8 @@ func Test_ParseBin(t *testing.T) {
require.Contains(t, app.Bin, scoop.Bin{Name: "bin\\gofmt.exe"})
})
t.Run("nested array (multiple shims)", func(t *testing.T) {
t.Parallel()

app := app(t, "extras/stash")

err := app.LoadDetails(scoop.DetailFieldBin)
Expand All @@ -79,6 +95,8 @@ func Test_ParseBin(t *testing.T) {
})
})
t.Run("nested array that contains arrays and strings", func(t *testing.T) {
t.Parallel()

app := app(t, "main/python")

err := app.LoadDetails(scoop.DetailFieldBin)
Expand All @@ -101,6 +119,8 @@ func Test_ParseBin(t *testing.T) {
}

func Test_ParseArchitecture_Items(t *testing.T) {
t.Parallel()

goApp := app(t, "main/go")

err := goApp.LoadDetails(scoop.DetailFieldArchitecture)
Expand Down

0 comments on commit 62346b2

Please sign in to comment.