├── .prettierrc
├── media
├── logo.png
├── banner.png
└── logo-transparent.png
├── .gitignore
├── scripts
├── version.sh
├── build.sh
└── tag-available.sh
├── .github
├── dependabot.yml
└── workflows
│ ├── build.yml
│ └── release.yml
├── utils
├── cli.go
├── slice.go
├── terminal.go
├── time.go
├── ansi.go
├── strings.go
├── io.go
├── prompt.go
├── system.go
├── files.go
└── logging.go
├── core
├── meta.go
├── http_source.go
├── local_source.go
├── source.go
├── transactions.go
├── config.go
├── app_config.go
├── asset.go
├── github_source.go
├── github_api.go
└── appimage.go
├── commands
├── app-config.go
├── list.go
├── view.go
├── run.go
├── tidy-broken.go
├── self-update.go
├── app-config-set-id.go
├── uninstall.go
├── install_http.go
├── install_local.go
├── update.go
├── install_github.go
├── init.go
└── install.go
├── go.mod
├── .vscode
└── settings.json
├── LICENSE
├── main.go
├── install.sh
├── go.sum
└── README.md
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "useTabs": false
4 | }
5 |
--------------------------------------------------------------------------------
/media/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zyrouge/pho/HEAD/media/logo.png
--------------------------------------------------------------------------------
/media/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zyrouge/pho/HEAD/media/banner.png
--------------------------------------------------------------------------------
/media/logo-transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zyrouge/pho/HEAD/media/logo-transparent.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.exe
2 | *.exe~
3 | *.dll
4 | *.so
5 | *.dylib
6 | *.test
7 | *.out
8 | go.work
9 | dist
10 |
--------------------------------------------------------------------------------
/scripts/version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | sed -nr 's/const AppVersion = "([[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+)"/\1/p' ./core/meta.go
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/scripts/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | for arch in "amd64" "386" "arm64" "arm"; do
4 | GOOS=linux GOARCH=$arch go build -ldflags "-s -w" -o ./dist/pho-$arch
5 | echo "Generated pho-$arch"
6 | done
7 |
--------------------------------------------------------------------------------
/utils/cli.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "github.com/urfave/cli/v3"
4 |
5 | func CommandBoolSetAndValue(cmd *cli.Command, name string) (bool, bool) {
6 | return cmd.IsSet(name), cmd.Bool(name)
7 | }
8 |
--------------------------------------------------------------------------------
/utils/slice.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func SliceContains[T comparable](slice []T, value T) bool {
4 | for _, x := range slice {
5 | if x == value {
6 | return true
7 | }
8 | }
9 | return false
10 | }
11 |
--------------------------------------------------------------------------------
/core/meta.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | const AppName = "Pho"
4 | const AppCodeName = "pho"
5 | const AppExecutableName = "pho"
6 | const AppDescription = "AppImage Manager"
7 | const AppVersion = "0.1.12"
8 | const AppGithubOwner = "zyrouge"
9 | const AppGithubRepo = "pho"
10 |
--------------------------------------------------------------------------------
/commands/app-config.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "github.com/urfave/cli/v3"
5 | )
6 |
7 | var AppConfigCommand = cli.Command{
8 | Name: "app-config",
9 | Aliases: []string{"ac"},
10 | Usage: "Related to application configuration",
11 | Commands: []*cli.Command{
12 | &AppConfigSetIdCommand,
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/scripts/tag-available.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | tag=$1
4 |
5 | if [ "$tag" == "" ]; then
6 | echo "No tag name specified"
7 | exit 1
8 | fi
9 |
10 | output=$(git ls-remote --exit-code --tags origin "$tag")
11 |
12 | if [ "$output" == "" ]; then
13 | echo "Tag $tag is available"
14 | exit 0
15 | fi
16 |
17 | echo "Tag $tag is not available"
18 | exit 1
19 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/zyrouge/pho
2 |
3 | go 1.21.1
4 |
5 | require (
6 | github.com/fatih/color v1.17.0
7 | github.com/urfave/cli/v3 v3.0.0-alpha9
8 | )
9 |
10 | require (
11 | github.com/mattn/go-colorable v0.1.13 // indirect
12 | github.com/mattn/go-isatty v0.0.20 // indirect
13 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
14 | golang.org/x/sys v0.19.0 // indirect
15 | )
16 |
--------------------------------------------------------------------------------
/utils/terminal.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "fmt"
4 |
5 | func TerminalErasePreviousLine() {
6 | fmt.Print(AnsiCursorLineUp(1), AnsiEraseLine, AnsiResetCursor)
7 | }
8 |
9 | // \ | / -
10 | func TerminalLoadingSymbol(v int) string {
11 | v = v % 4
12 | switch v {
13 | case 1:
14 | return "\\"
15 |
16 | case 2:
17 | return "|"
18 |
19 | case 3:
20 | return "/"
21 |
22 | default:
23 | return "-"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/utils/time.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | func TimeNowSeconds() int64 {
9 | return time.Now().Unix()
10 | }
11 |
12 | func HumanizeSeconds(n int64) string {
13 | s := n % 60
14 | n /= 60
15 | m := n % 60
16 | h := n / 60
17 | if h == 0 && m == 0 {
18 | return fmt.Sprintf("%ds", s)
19 | }
20 | if h == 0 {
21 | return fmt.Sprintf("%dm:%ds", m, s)
22 | }
23 | return fmt.Sprintf("%dh:%dm:%ds", h, m, s)
24 | }
25 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "[go]": {
5 | "editor.insertSpaces": false,
6 | "editor.formatOnSave": true,
7 | "editor.codeActionsOnSave": {
8 | "source.organizeImports": "explicit"
9 | },
10 | "editor.defaultFormatter": "golang.go"
11 | },
12 | "[shellscript]": {
13 | "editor.defaultFormatter": "foxundermoon.shell-format"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/core/http_source.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/zyrouge/pho/utils"
7 | )
8 |
9 | const HttpSourceId SourceId = "http"
10 |
11 | type HttpSource struct{}
12 |
13 | func ReadHttpSourceConfig(configPath string) (*HttpSource, error) {
14 | return utils.ReadJsonFile[HttpSource](configPath)
15 | }
16 |
17 | func (*HttpSource) SupportsUpdates() bool {
18 | return false
19 | }
20 |
21 | func (*HttpSource) CheckUpdate(app *AppConfig, reinstall bool) (*SourceUpdate, error) {
22 | return nil, errors.New("http source does not support updates")
23 | }
24 |
--------------------------------------------------------------------------------
/core/local_source.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/zyrouge/pho/utils"
7 | )
8 |
9 | const LocalSourceId SourceId = "local"
10 |
11 | type LocalSource struct{}
12 |
13 | func ReadLocalSourceConfig(configPath string) (*LocalSource, error) {
14 | return utils.ReadJsonFile[LocalSource](configPath)
15 | }
16 |
17 | func (*LocalSource) SupportUpdates() bool {
18 | return false
19 | }
20 |
21 | func (*LocalSource) CheckUpdate(app *AppConfig, reinstall bool) (*SourceUpdate, error) {
22 | return nil, errors.New("local source does not support updates")
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | env:
7 | BUILD_DIR: ./dist
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - uses: actions/setup-go@v4
17 | with:
18 | go-version-file: go.mod
19 | cache-dependency-path: go.sum
20 |
21 | - name: 🔨 Build executables
22 | run: ./scripts/build.sh
23 |
24 | - name: 🚀 Upload executables
25 | uses: actions/upload-artifact@v3
26 | with:
27 | path: ${{ env.BUILD_DIR }}
28 |
--------------------------------------------------------------------------------
/utils/ansi.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | )
7 |
8 | // Source: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797
9 |
10 | const AnsiEsc = "\x1B"
11 | const AnsiResetCursor = "\r"
12 |
13 | var AnsiEraseLine = fmt.Sprintf("%s[K", AnsiEsc)
14 |
15 | func AnsiCursorLineUp(lines int) string {
16 | return fmt.Sprintf("%s[%dA", AnsiEsc, lines)
17 | }
18 |
19 | func AnsiCursorToColumn(offset int) string {
20 | return fmt.Sprintf("%s[%dG", AnsiEsc, offset)
21 | }
22 |
23 | // Source: https://stackoverflow.com/questions/17998978/removing-colors-from-output#comment117315951_35582778
24 | var stripAnsiRegex = regexp.MustCompile(`\x1B\[(?:;?[0-9]{1,3})+[mGK]`)
25 |
26 | func StripAnsi(text string) string {
27 | return stripAnsiRegex.ReplaceAllLiteralString(text, "")
28 | }
29 |
--------------------------------------------------------------------------------
/core/source.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import "errors"
4 |
5 | type Source interface {
6 | SupportUpdates() bool
7 | CheckUpdate(app *AppConfig, reinstall bool) (*SourceUpdate, error)
8 | }
9 |
10 | type SourceUpdate struct {
11 | Version string
12 | MatchScore AppImageAssetMatch
13 | *Asset
14 | }
15 |
16 | func ReadSourceConfig(sourceId SourceId, sourcePath string) (any, error) {
17 | switch sourceId {
18 | case GithubSourceId:
19 | return ReadGithubSourceConfig(sourcePath)
20 |
21 | case HttpSourceId:
22 | return ReadHttpSourceConfig(sourcePath)
23 |
24 | case LocalSourceId:
25 | return ReadLocalSourceConfig(sourcePath)
26 |
27 | default:
28 | return nil, errors.New("invalid source id")
29 | }
30 | }
31 |
32 | func CastSourceConfigAsSource(config any) (Source, error) {
33 | source, ok := config.(Source)
34 | if !ok {
35 | return nil, errors.New("config does not implement source")
36 | }
37 | return source, nil
38 | }
39 |
--------------------------------------------------------------------------------
/utils/strings.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | )
7 |
8 | var cleanTextInvalidRegex = regexp.MustCompile(`[^A-Za-z0-9-]+`)
9 | var cleanTextTrimRegex = regexp.MustCompile(`(^-|-$)`)
10 |
11 | func ReplaceIllegalChars(text string) string {
12 | text = cleanTextInvalidRegex.ReplaceAllLiteralString(text, "")
13 | text = cleanTextTrimRegex.ReplaceAllLiteralString(text, "")
14 | return text
15 | }
16 |
17 | func CleanId(text string) string {
18 | text = ReplaceIllegalChars(text)
19 | text = strings.ToLower(text)
20 | return text
21 | }
22 |
23 | func QuotedWhenSpace(text string) string {
24 | return EncloseWhen(text, " ", `"`, `"`)
25 | }
26 |
27 | func EncloseWhen(text string, when string, start string, end string) string {
28 | if !strings.Contains(text, when) {
29 | return text
30 | }
31 | return start + text + end
32 | }
33 |
34 | func BoolToYesNo(value bool) string {
35 | if value {
36 | return "yes"
37 | }
38 | return "no"
39 | }
40 |
--------------------------------------------------------------------------------
/utils/io.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | )
10 |
11 | func ReaderReadLine(reader *bufio.Reader) (string, error) {
12 | input, err := reader.ReadString('\n')
13 | if err != nil {
14 | return "", err
15 | }
16 | return strings.TrimSuffix(input, "\n"), nil
17 | }
18 |
19 | func FileExists(name string) (bool, error) {
20 | _, err := os.Stat(name)
21 | if err == nil {
22 | return true, nil
23 | }
24 | if errors.Is(err, os.ErrNotExist) {
25 | return false, nil
26 | }
27 | return false, err
28 | }
29 |
30 | func ResolvePath(name string) (string, error) {
31 | if name == "" {
32 | return "", errors.New("cannot resolve to empty path")
33 | }
34 | if strings.HasPrefix(name, "~/") {
35 | home, err := os.UserHomeDir()
36 | if err != nil {
37 | return "", err
38 | }
39 | name = filepath.Join(home, name[2:])
40 | }
41 | name, err := filepath.Abs(name)
42 | if err != nil {
43 | return "", err
44 | }
45 | return name, err
46 | }
47 |
--------------------------------------------------------------------------------
/commands/list.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/fatih/color"
8 | "github.com/urfave/cli/v3"
9 | "github.com/zyrouge/pho/core"
10 | "github.com/zyrouge/pho/utils"
11 | )
12 |
13 | var ListCommand = cli.Command{
14 | Name: "list",
15 | Aliases: []string{"installed"},
16 | Usage: "List all installed applications",
17 | Action: func(_ context.Context, cmd *cli.Command) error {
18 | utils.LogDebug("reading config")
19 | config, err := core.GetConfig()
20 | if err != nil {
21 | return err
22 | }
23 |
24 | utils.LogLn()
25 | summary := utils.NewLogTable()
26 | headingColor := color.New(color.Underline, color.Bold)
27 | summary.Add(
28 | headingColor.Sprint("Index"),
29 | headingColor.Sprint("Application ID"),
30 | )
31 | i := 0
32 | for appId := range config.Installed {
33 | i++
34 | summary.Add(fmt.Sprintf("%d.", i), color.CyanString(appId))
35 | }
36 | summary.Print()
37 | if i == 0 {
38 | utils.LogInfo(color.HiBlackString("no applications are installed"))
39 | }
40 | utils.LogLn()
41 |
42 | return nil
43 | },
44 | }
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Zyrouge
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "os"
6 |
7 | "github.com/urfave/cli/v3"
8 | "github.com/zyrouge/pho/commands"
9 | "github.com/zyrouge/pho/core"
10 | "github.com/zyrouge/pho/utils"
11 | )
12 |
13 | func main() {
14 | app := &cli.Command{
15 | Name: core.AppExecutableName,
16 | Usage: core.AppDescription,
17 | Version: core.AppVersion,
18 | Flags: []cli.Flag{
19 | &cli.BoolFlag{
20 | Name: "verbose",
21 | Persistent: true,
22 | Action: func(_ context.Context, _ *cli.Command, b bool) error {
23 | utils.LogDebugEnabled = b
24 | return nil
25 | },
26 | },
27 | },
28 | Authors: []any{"Zyrouge"},
29 | Commands: []*cli.Command{
30 | &commands.InitCommand,
31 | &commands.InstallCommand,
32 | &commands.UninstallCommand,
33 | &commands.UpdateCommand,
34 | &commands.RunCommand,
35 | &commands.ListCommand,
36 | &commands.ViewCommand,
37 | &commands.TidyBrokenCommand,
38 | &commands.SelfUpdateCommand,
39 | &commands.AppConfigCommand,
40 | },
41 | EnableShellCompletion: true,
42 | }
43 |
44 | if err := app.Run(context.Background(), os.Args); err != nil {
45 | utils.LogError(err)
46 | os.Exit(1)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | permissions:
7 | contents: write
8 |
9 | env:
10 | APP_VERSION: ""
11 | TAG_NAME: ""
12 | BUILD_DIR: ./dist
13 |
14 | jobs:
15 | release:
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 |
21 | - uses: actions/setup-go@v4
22 | with:
23 | go-version-file: go.mod
24 | cache-dependency-path: go.sum
25 |
26 | - name: 🔢 Get version
27 | run: |
28 | version=$(./scripts/version.sh)
29 | echo "APP_VERSION=${version}" >> $GITHUB_ENV
30 | echo "TAG_NAME=v${version}" >> $GITHUB_ENV
31 |
32 | - name: 🔎 Check tag availability
33 | run: ./scripts/tag-available.sh $TAG_NAME
34 |
35 | - name: 🔨 Build executables
36 | run: ./scripts/build.sh
37 |
38 | - name: 🚀 Upload executables
39 | uses: ncipollo/release-action@v1
40 | with:
41 | tag: ${{ env.TAG_NAME }}
42 | artifacts: ${{ env.BUILD_DIR }}/*
43 | generateReleaseNotes: true
44 | draft: true
45 | artifactErrorsFailBuild: true
46 |
--------------------------------------------------------------------------------
/utils/prompt.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/fatih/color"
9 | )
10 |
11 | func PromptTextInput(reader *bufio.Reader, question string, defaultValue string) (string, error) {
12 | prefix := fmt.Sprintf(
13 | "%s %s",
14 | LogQuestionPrefix,
15 | question,
16 | )
17 | fmt.Printf("%s %s ", prefix, color.HiBlackString(defaultValue))
18 | input, err := reader.ReadString('\n')
19 | if err != nil {
20 | return "", err
21 | }
22 | input = strings.TrimSuffix(input, "\n")
23 | if input == "" {
24 | input = defaultValue
25 | }
26 | fmt.Print(AnsiCursorLineUp(1), AnsiEraseLine, AnsiResetCursor)
27 | fmt.Printf("%s %s\n", prefix, color.CyanString(input))
28 | return input, nil
29 | }
30 |
31 | func PromptYesNoInput(reader *bufio.Reader, question string) (bool, error) {
32 | prefix := fmt.Sprintf(
33 | "%s %s",
34 | LogQuestionPrefix,
35 | question,
36 | )
37 | fmt.Printf("%s %s ", prefix, color.HiBlackString("[y/N]"))
38 | input, err := reader.ReadString('\n')
39 | if err != nil {
40 | return false, err
41 | }
42 | input = strings.TrimSuffix(input, "\n")
43 | input = strings.ToLower(input)
44 | if input == "y" || input == "yes" {
45 | input = "yes"
46 | } else {
47 | input = "no"
48 | }
49 | TerminalErasePreviousLine()
50 | fmt.Printf("%s %s\n", prefix, color.CyanString(input))
51 | return input == "yes", nil
52 | }
53 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | if
6 | command -v pho >/dev/null
7 | then
8 | echo "Pho is already installed! Exiting..."
9 | exit 1
10 | fi
11 |
12 | install_dir="${HOME}/.local/bin"
13 | if [ -n "$1" ]; then
14 | install_dir=$(readlink -f "$1")
15 | fi
16 | install_dir="${install_dir%/}"
17 | if ! [[ -d "${install_dir}" ]]; then
18 | echo "[error] Installation directory '${install_dir}' does not exist."
19 | exit 1
20 | fi
21 |
22 | install_path="${install_dir}/pho"
23 | echo "Installation path set as '${install_dir}'"
24 | if ! [[ "${PATH}" == *":${install_dir}"* ]] && ! [[ "${PATH}" == *"${install_dir}:"* ]]; then
25 | echo "Kindly ensure that the installation directory exists in \$PATH variable."
26 | fi
27 |
28 | arch=$(uname -m)
29 | case "${arch}" in
30 | "x86_64")
31 | arch="amd64"
32 | ;;
33 | "i386")
34 | arch="386"
35 | ;;
36 | "i686")
37 | arch="386"
38 | ;;
39 | "armhf")
40 | arch="arm64"
41 | ;;
42 | "aarch64")
43 | arch="arm64"
44 | ;;
45 | esac
46 | echo "Architecture found to be '${arch}'"
47 |
48 | release_url="https://api.github.com/repos/zyrouge/pho/releases/latest"
49 | download_url=$(
50 | curl -Ls --fail "${release_url}" |
51 | grep -E "\"browser_download_url\".*pho-${arch}\"" |
52 | sed -nr 's/.*"([^"]+)"$/\1/p'
53 | )
54 | if [[ "${download_url}" == "" ]]; then
55 | echo "[error] Unsupported platform or architecture."
56 | exit 1
57 | fi
58 |
59 | echo "Downloading binary from '${download_url}'..."
60 | curl --fail -Ls -o "${install_path}" "${download_url}"
61 | chmod +x "${install_path}"
62 |
63 | echo "Installation succeeded!"
64 | echo "You can get started by using 'pho init' to initialize Pho!"
65 |
--------------------------------------------------------------------------------
/core/transactions.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "path"
7 |
8 | "github.com/zyrouge/pho/utils"
9 | )
10 |
11 | type PendingInstallation struct {
12 | InvolvedDirs []string
13 | InvolvedFiles []string
14 | }
15 |
16 | type Transactions struct {
17 | PendingInstallations map[string]PendingInstallation
18 | }
19 |
20 | func GetTransactionsPath() (string, error) {
21 | xdgConfigDir, err := os.UserConfigDir()
22 | if err != nil {
23 | return "", err
24 | }
25 | transtionsPath := path.Join(xdgConfigDir, AppCodeName, "transactions.json")
26 | return transtionsPath, nil
27 | }
28 |
29 | func GetTransactions() (*Transactions, error) {
30 | transtionsPath, err := GetTransactionsPath()
31 | if err != nil {
32 | return nil, err
33 | }
34 | transactions, err := utils.ReadJsonFile[Transactions](transtionsPath)
35 | if errors.Is(err, os.ErrNotExist) {
36 | transactions = &Transactions{
37 | PendingInstallations: map[string]PendingInstallation{},
38 | }
39 | err = nil
40 | }
41 | return transactions, err
42 | }
43 |
44 | func SaveTransactions(transactions *Transactions) error {
45 | transtionsPath, err := GetTransactionsPath()
46 | if err != nil {
47 | return err
48 | }
49 | err = utils.WriteJsonFileAtomic[Transactions](transtionsPath, transactions)
50 | if err != nil {
51 | return err
52 | }
53 | return nil
54 | }
55 |
56 | type UpdateTransactionFunc func(transactions *Transactions) error
57 |
58 | func UpdateTransactions(performer UpdateTransactionFunc) error {
59 | transactions, err := GetTransactions()
60 | if err != nil {
61 | return err
62 | }
63 | err = performer(transactions)
64 | if err != nil {
65 | return err
66 | }
67 | return SaveTransactions(transactions)
68 | }
69 |
--------------------------------------------------------------------------------
/utils/system.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 | "strings"
7 | "syscall"
8 | )
9 |
10 | var ArchMap = map[string][]string{
11 | "amd64": {"amd64", "x86_64", "x86-64"},
12 | "386": {"i386", "i686"},
13 | "arm64": {"armhf", "aarch64"},
14 | "arm": {"arm"},
15 | }
16 |
17 | func GetSystemArch() string {
18 | raw, _ := ExecUnameM()
19 | for arch, aliases := range ArchMap {
20 | for _, alias := range aliases {
21 | if alias == raw {
22 | return arch
23 | }
24 | }
25 | }
26 | return ""
27 | }
28 |
29 | func ExecUnameM() (string, error) {
30 | cmd := exec.Command("uname", "-m")
31 | output, err := cmd.Output()
32 | if err != nil {
33 | return "", err
34 | }
35 | return strings.TrimSpace(string(output)), nil
36 | }
37 |
38 | type StartDetachedProcessOptions struct {
39 | Dir string
40 | Exec string
41 | Args []string
42 | }
43 |
44 | func StartDetachedProcess(options *StartDetachedProcessOptions) error {
45 | stdin, err := os.Open(os.DevNull)
46 | if err != nil {
47 | return err
48 | }
49 | stdout, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
50 | if err != nil {
51 | return err
52 | }
53 | stderr, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
54 | if err != nil {
55 | return err
56 | }
57 | procAttr := &os.ProcAttr{
58 | Dir: options.Dir,
59 | Env: os.Environ(),
60 | Files: []*os.File{
61 | stdin,
62 | stdout,
63 | stderr,
64 | },
65 | Sys: &syscall.SysProcAttr{
66 | Foreground: true,
67 | },
68 | }
69 | argv := append([]string{options.Exec}, options.Args...)
70 | proc, err := os.StartProcess(options.Exec, argv, procAttr)
71 | if err != nil {
72 | return err
73 | }
74 | if err = proc.Release(); err != nil {
75 | return err
76 | }
77 | return nil
78 | }
79 |
--------------------------------------------------------------------------------
/core/config.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "path"
8 |
9 | "github.com/fatih/color"
10 | "github.com/zyrouge/pho/utils"
11 | )
12 |
13 | type Config struct {
14 | AppsDir string `json:"AppsDir"`
15 | DesktopDir string `json:"DesktopDir"`
16 | Installed map[string]string `json:"Installed"`
17 | EnableIntegrationPrompt bool `json:"EnableIntegrationPrompt"`
18 | SymlinksDir string `json:"SymlinksDir"`
19 | }
20 |
21 | var cachedConfig *Config
22 |
23 | func GetConfigPath() (string, error) {
24 | xdgConfigDir, err := os.UserConfigDir()
25 | if err != nil {
26 | return "", err
27 | }
28 | configPath := path.Join(xdgConfigDir, AppCodeName, "config.json")
29 | return configPath, nil
30 | }
31 |
32 | func ReadConfig() (*Config, error) {
33 | cachedConfig = nil
34 | configPath, err := GetConfigPath()
35 | if err != nil {
36 | return nil, err
37 | }
38 | config, err := utils.ReadJsonFile[Config](configPath)
39 | if errors.Is(err, os.ErrNotExist) {
40 | return nil, fmt.Errorf(
41 | "config file does not exist, use %s %s to initiate the setup",
42 | color.CyanString(AppExecutableName),
43 | color.CyanString("init"),
44 | )
45 | }
46 | if err != nil {
47 | return nil, err
48 | }
49 | cachedConfig = config
50 | return config, nil
51 | }
52 |
53 | func SaveConfig(config *Config) error {
54 | configPath, err := GetConfigPath()
55 | if err != nil {
56 | return err
57 | }
58 | err = utils.WriteJsonFileAtomic[Config](configPath, config)
59 | if err != nil {
60 | return err
61 | }
62 | cachedConfig = config
63 | return nil
64 | }
65 |
66 | func GetConfig() (*Config, error) {
67 | if cachedConfig == nil {
68 | return ReadConfig()
69 | }
70 | return cachedConfig, nil
71 | }
72 |
--------------------------------------------------------------------------------
/commands/view.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/fatih/color"
9 | "github.com/urfave/cli/v3"
10 | "github.com/zyrouge/pho/core"
11 | "github.com/zyrouge/pho/utils"
12 | )
13 |
14 | var ViewCommand = cli.Command{
15 | Name: "view",
16 | Aliases: []string{},
17 | Usage: "View an installed application",
18 | Action: func(_ context.Context, cmd *cli.Command) error {
19 | utils.LogDebug("reading config")
20 | config, err := core.GetConfig()
21 | if err != nil {
22 | return err
23 | }
24 |
25 | args := cmd.Args()
26 | if args.Len() == 0 {
27 | return errors.New("no application id specified")
28 | }
29 | if args.Len() > 1 {
30 | return errors.New("unexpected excessive arguments")
31 | }
32 |
33 | appId := args.Get(0)
34 | utils.LogDebug(fmt.Sprintf("argument id: %s", appId))
35 |
36 | if _, ok := config.Installed[appId]; !ok {
37 | return fmt.Errorf(
38 | "application with id %s is not installed",
39 | color.CyanString(appId),
40 | )
41 | }
42 |
43 | appConfigPath := core.GetAppConfigPath(config, appId)
44 | utils.LogDebug(fmt.Sprintf("reading app config from %s", appConfigPath))
45 | app, err := core.ReadAppConfig(appConfigPath)
46 | if err != nil {
47 | return err
48 | }
49 |
50 | utils.LogLn()
51 | summary := utils.NewLogTable()
52 | summary.Add(utils.LogRightArrowPrefix, "Identifier", color.CyanString(app.Id))
53 | summary.Add(utils.LogRightArrowPrefix, "Version", color.CyanString(app.Version))
54 | summary.Add(utils.LogRightArrowPrefix, "Source", color.CyanString(string(app.Source)))
55 | summary.Add(utils.LogRightArrowPrefix, "Directory", color.CyanString(app.Paths.Dir))
56 | summary.Add(utils.LogRightArrowPrefix, "AppImage", color.CyanString(app.Paths.AppImage))
57 | summary.Add(utils.LogRightArrowPrefix, "Icon", color.CyanString(app.Paths.Icon))
58 | summary.Add(utils.LogRightArrowPrefix, ".desktop file", color.CyanString(app.Paths.Desktop))
59 | summary.Print()
60 | utils.LogLn()
61 |
62 | return nil
63 | },
64 | }
65 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
4 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
5 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
6 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
7 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
8 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
9 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
12 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
13 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
14 | github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo=
15 | github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc=
16 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
17 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
18 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
19 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
21 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
22 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
23 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
24 |
--------------------------------------------------------------------------------
/core/app_config.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "path"
6 |
7 | "github.com/zyrouge/pho/utils"
8 | )
9 |
10 | type AppConfig struct {
11 | Id string `json:"Id"`
12 | Version string `json:"Version"`
13 | Source SourceId `json:"Source"`
14 | Paths AppPaths `json:"Paths"`
15 | }
16 |
17 | type SourceId string
18 |
19 | type AppPaths struct {
20 | Dir string `json:"Dir"`
21 | Config string `json:"Config"`
22 | SourceConfig string `json:"SourceConfig"`
23 | AppImage string `json:"AppImage"`
24 | Icon string `json:"Icon"`
25 | Desktop string `json:"Desktop"`
26 | Symlink string `json:"Symlink"`
27 | }
28 |
29 | func ReadAppConfig(configPath string) (*AppConfig, error) {
30 | return utils.ReadJsonFile[AppConfig](configPath)
31 | }
32 |
33 | func SaveAppConfig(configPath string, config *AppConfig) error {
34 | return utils.WriteJsonFile[AppConfig](configPath, config)
35 | }
36 |
37 | func SaveSourceConfig[T any](configPath string, config T) error {
38 | return utils.WriteJsonFile[T](configPath, &config)
39 | }
40 |
41 | func ConstructAppId(appName string) string {
42 | return utils.CleanId(appName)
43 | }
44 |
45 | type ConstructAppPathsOptions struct {
46 | Symlink bool
47 | }
48 |
49 | func ConstructAppPaths(config *Config, appId string, options *ConstructAppPathsOptions) *AppPaths {
50 | appDir := path.Join(config.AppsDir, appId)
51 | symlinkPath := ""
52 | if config.SymlinksDir != "" && options.Symlink {
53 | symlinkPath = path.Join(config.SymlinksDir, appId)
54 | }
55 | return &AppPaths{
56 | Dir: appDir,
57 | Config: path.Join(appDir, "config.pho.json"),
58 | SourceConfig: path.Join(appDir, "source.pho.json"),
59 | AppImage: path.Join(appDir, fmt.Sprintf("%s.AppImage", appId)),
60 | Icon: path.Join(appDir, fmt.Sprintf("%s.png", appId)),
61 | Desktop: path.Join(config.DesktopDir, fmt.Sprintf("%s.desktop", appId)),
62 | Symlink: symlinkPath,
63 | }
64 | }
65 |
66 | func GetAppConfigPath(config *Config, appId string) string {
67 | return config.Installed[appId]
68 | }
69 |
--------------------------------------------------------------------------------
/utils/files.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path"
8 | "strings"
9 | )
10 |
11 | var AtomicFilePrefix = "pho"
12 |
13 | func CreateTempFile(name string) (*os.File, error) {
14 | dir := path.Dir(name)
15 | ext := path.Ext(name)
16 | return os.CreateTemp(
17 | dir,
18 | fmt.Sprintf(
19 | "%s-%s-*%s",
20 | AtomicFilePrefix,
21 | strings.TrimSuffix(path.Base(name), ext),
22 | ext,
23 | ),
24 | )
25 | }
26 |
27 | func WriteFileAtomic(name string, bytes []byte) error {
28 | temp, err := CreateTempFile(name)
29 | if err != nil {
30 | return err
31 | }
32 | tempName := temp.Name()
33 | closed, deleted := false, false
34 | defer func() {
35 | if !closed {
36 | temp.Close()
37 | }
38 | if !deleted {
39 | os.Remove(tempName)
40 | }
41 | }()
42 | if _, err = temp.Write(bytes); err != nil {
43 | return err
44 | }
45 | if err = temp.Sync(); err != nil {
46 | return err
47 | }
48 | if err = temp.Close(); err != nil {
49 | return err
50 | }
51 | closed = true
52 | err = os.Rename(tempName, name)
53 | if err != nil {
54 | return err
55 | }
56 | deleted = true
57 | return nil
58 | }
59 |
60 | func ReadJsonFile[T any](name string) (*T, error) {
61 | file, err := os.Open(name)
62 | if err != nil {
63 | return nil, err
64 | }
65 | defer file.Close()
66 | var data T
67 | decoder := json.NewDecoder(file)
68 | if err = decoder.Decode(&data); err != nil {
69 | return nil, err
70 | }
71 | return &data, nil
72 | }
73 |
74 | func WriteJsonFile[T any](name string, data *T) error {
75 | json, err := json.Marshal(data)
76 | if err != nil {
77 | return err
78 | }
79 | return os.WriteFile(name, json, os.ModePerm)
80 | }
81 |
82 | func WriteJsonFileAtomic[T any](name string, data *T) error {
83 | json, err := json.Marshal(data)
84 | if err != nil {
85 | return err
86 | }
87 | return WriteFileAtomic(name, json)
88 | }
89 |
90 | func FindFileInDir(dir string, names []string) (bool, string) {
91 | for _, x := range names {
92 | p := path.Join(dir, x)
93 | if _, err := os.Lstat(p); err == nil {
94 | return true, p
95 | }
96 | }
97 | return false, ""
98 | }
99 |
--------------------------------------------------------------------------------
/core/asset.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | "os"
7 | "strings"
8 |
9 | "github.com/zyrouge/pho/utils"
10 | )
11 |
12 | type AssetDownloadFunc func() (io.ReadCloser, error)
13 |
14 | type Asset struct {
15 | Source string
16 | Size int64
17 | Download AssetDownloadFunc
18 | }
19 |
20 | func NetworkAssetDownload(url string) AssetDownloadFunc {
21 | return func() (io.ReadCloser, error) {
22 | res, err := http.Get(url)
23 | if err != nil {
24 | return nil, err
25 | }
26 | return res.Body, nil
27 | }
28 | }
29 |
30 | func LocalAssetDownload(name string) AssetDownloadFunc {
31 | return func() (io.ReadCloser, error) {
32 | file, err := os.Open(name)
33 | if err != nil {
34 | return nil, err
35 | }
36 | return file, nil
37 | }
38 | }
39 |
40 | type NetworkAssetMetadata struct {
41 | Size int64
42 | }
43 |
44 | func ExtractNetworkAssetMetadata(url string) (*NetworkAssetMetadata, error) {
45 | res, err := http.Get(url)
46 | if err != nil {
47 | return nil, err
48 | }
49 | defer res.Body.Close()
50 | metadata := &NetworkAssetMetadata{
51 | Size: res.ContentLength,
52 | }
53 | return metadata, nil
54 | }
55 |
56 | type AppImageAssetMatch int
57 |
58 | const (
59 | AppImageAssetNoMatch AppImageAssetMatch = iota
60 | AppImageAssetPartialMatch
61 | AppImageAssetExactMatch
62 | )
63 |
64 | func ChooseAptAppImageAsset[T any](assets []T, assetNameFunc func(*T) string) (AppImageAssetMatch, *T) {
65 | arch := utils.GetSystemArch()
66 | var fallback *T
67 | for i := range assets {
68 | asset := &assets[i]
69 | name := strings.ToLower(assetNameFunc(asset))
70 | if !strings.HasSuffix(name, ".appimage") {
71 | continue
72 | }
73 | matchedArch := extractArch(name)
74 | if matchedArch == arch {
75 | return AppImageAssetExactMatch, asset
76 | }
77 | // no arch probably means they didnt include it
78 | if matchedArch == "" {
79 | fallback = asset
80 | }
81 | }
82 | if fallback != nil {
83 | return AppImageAssetPartialMatch, fallback
84 | }
85 | return AppImageAssetNoMatch, nil
86 | }
87 |
88 | func extractArch(name string) string {
89 | for arch, aliases := range utils.ArchMap {
90 | for _, x := range aliases {
91 | if strings.Contains(name, x) {
92 | return arch
93 | }
94 | }
95 | }
96 | return ""
97 | }
98 |
--------------------------------------------------------------------------------
/core/github_source.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "github.com/zyrouge/pho/utils"
8 | )
9 |
10 | const GithubSourceId SourceId = "github"
11 |
12 | type GithubSourceRelease string
13 |
14 | const (
15 | GithubSourceReleaseLatest GithubSourceRelease = "latest"
16 | GithubSourceReleasePreRelease GithubSourceRelease = "prerelease"
17 | GithubSourceReleaseTagged GithubSourceRelease = "tag"
18 | GithubSourceReleaseAny GithubSourceRelease = "any"
19 | )
20 |
21 | type GithubSource struct {
22 | UserName string `json:"UserName"`
23 | RepoName string `json:"RepoName"`
24 | Release GithubSourceRelease `json:"Release"`
25 | TagName string `json:"TagName"`
26 | }
27 |
28 | func ReadGithubSourceConfig(configPath string) (*GithubSource, error) {
29 | return utils.ReadJsonFile[GithubSource](configPath)
30 | }
31 |
32 | func (source *GithubSource) FetchAptRelease() (*GithubApiRelease, error) {
33 | return source.FetchAptLatestRelease()
34 | }
35 |
36 | func (source *GithubSource) FetchAptLatestRelease() (*GithubApiRelease, error) {
37 | switch source.Release {
38 | case GithubSourceReleaseLatest:
39 | return GithubApiFetchLatestRelease(source.UserName, source.RepoName)
40 |
41 | case GithubSourceReleasePreRelease:
42 | return GithubApiFetchLatestPreRelease(source.UserName, source.RepoName)
43 |
44 | case GithubSourceReleaseTagged:
45 | return GithubApiFetchTaggedRelease(source.UserName, source.RepoName, source.TagName)
46 |
47 | case GithubSourceReleaseAny:
48 | return GithubApiFetchLatestAny(source.UserName, source.RepoName)
49 |
50 | default:
51 | return nil, errors.New("invalid github source release type")
52 | }
53 | }
54 |
55 | func (release *GithubApiRelease) ChooseAptAsset() (AppImageAssetMatch, *GithubApiReleaseAsset) {
56 | return ChooseAptAppImageAsset(
57 | release.Assets,
58 | func(x *GithubApiReleaseAsset) string {
59 | return x.Name
60 | },
61 | )
62 | }
63 |
64 | func (source *GithubSource) SupportUpdates() bool {
65 | return true
66 | }
67 |
68 | func (source *GithubSource) CheckUpdate(app *AppConfig, reinstall bool) (*SourceUpdate, error) {
69 | release, err := source.FetchAptLatestRelease()
70 | if err != nil {
71 | return nil, err
72 | }
73 | if app.Version == release.TagName && !reinstall {
74 | return nil, nil
75 | }
76 | matchScore, asset := release.ChooseAptAsset()
77 | if matchScore == AppImageAssetNoMatch {
78 | return nil, fmt.Errorf("no valid asset in github tag %s", release.TagName)
79 | }
80 | update := &SourceUpdate{
81 | Version: release.TagName,
82 | MatchScore: matchScore,
83 | Asset: asset.ToAsset(),
84 | }
85 | return update, nil
86 | }
87 |
--------------------------------------------------------------------------------
/commands/run.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "os/exec"
9 | "strings"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var RunCommand = cli.Command{
18 | Name: "run",
19 | Aliases: []string{"launch"},
20 | Usage: "Run an application",
21 | Flags: []cli.Flag{
22 | &cli.BoolFlag{
23 | Name: "detached",
24 | Aliases: []string{"d"},
25 | Usage: "Run as a detached process",
26 | },
27 | },
28 | Action: func(_ context.Context, cmd *cli.Command) error {
29 | utils.LogDebug("reading config")
30 | config, err := core.GetConfig()
31 | if err != nil {
32 | return err
33 | }
34 |
35 | args := cmd.Args()
36 | hasExecArgs := args.Get(1) == "--"
37 | if args.Len() == 0 {
38 | return errors.New("no application id specified")
39 | }
40 | if args.Len() > 1 && !hasExecArgs {
41 | return errors.New("unexpected excessive arguments")
42 | }
43 |
44 | appId := args.Get(0)
45 | execArgs := []string{}
46 | if hasExecArgs {
47 | execArgs = args.Slice()[2:]
48 | }
49 | detached := cmd.Bool("detached")
50 | utils.LogDebug(fmt.Sprintf("argument id: %s", appId))
51 | utils.LogDebug(fmt.Sprintf("argument exec-args: %s", strings.Join(execArgs, " ")))
52 | utils.LogDebug(fmt.Sprintf("argument detached: %v", detached))
53 |
54 | if _, ok := config.Installed[appId]; !ok {
55 | return fmt.Errorf(
56 | "application with id %s is not installed",
57 | color.CyanString(appId),
58 | )
59 | }
60 |
61 | appConfigPath := core.GetAppConfigPath(config, appId)
62 | utils.LogDebug(fmt.Sprintf("reading app config from %s", appConfigPath))
63 | app, err := core.ReadAppConfig(appConfigPath)
64 | if err != nil {
65 | return err
66 | }
67 |
68 | execPath := app.Paths.AppImage
69 | execDir, err := os.Getwd()
70 | if err != nil {
71 | return err
72 | }
73 | utils.LogDebug(fmt.Sprintf("exec path as %s", execPath))
74 | utils.LogDebug(fmt.Sprintf("exec dir as %s", execDir))
75 |
76 | if detached {
77 | detachedOptions := &utils.StartDetachedProcessOptions{
78 | Dir: execDir,
79 | Exec: execPath,
80 | Args: execArgs,
81 | }
82 | if err = utils.StartDetachedProcess(detachedOptions); err != nil {
83 | return err
84 | }
85 | utils.LogDebug("launched detached process successfully")
86 | return nil
87 | }
88 |
89 | proc := exec.Command(execPath)
90 | proc.Dir = execDir
91 | proc.Stdin = os.Stdin
92 | proc.Stdout = os.Stdout
93 | proc.Stderr = os.Stderr
94 | if err = proc.Run(); err != nil {
95 | return err
96 | }
97 | return nil
98 | },
99 | }
100 |
--------------------------------------------------------------------------------
/utils/logging.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strings"
7 |
8 | "github.com/fatih/color"
9 | )
10 |
11 | var LogDebugPrefix = color.HiBlackString("debug:")
12 | var LogWarnPrefix = color.YellowString("warning")
13 | var LogErrorPrefix = color.RedString("error:")
14 |
15 | var LogQuestionPrefix = color.GreenString("?")
16 | var LogRightArrowPrefix = color.MagentaString(">")
17 | var LogTickPrefix = color.GreenString("√")
18 | var LogExclamationPrefix = color.RedString("!")
19 |
20 | var LogDebugEnabled = os.Getenv("DEBUG") == "1"
21 |
22 | func LogInfo(msg string) {
23 | fmt.Println(msg)
24 | }
25 |
26 | func LogDebug(msg string) {
27 | if !LogDebugEnabled {
28 | return
29 | }
30 | fmt.Printf("%s %s\n", LogDebugPrefix, msg)
31 | }
32 |
33 | func LogWarning(msg string) {
34 | fmt.Printf("%s %s\n", LogWarnPrefix, msg)
35 | }
36 |
37 | func LogError(err any) {
38 | fmt.Printf("%s %v\n", LogErrorPrefix, err)
39 | }
40 |
41 | func LogLn() {
42 | fmt.Println()
43 | }
44 |
45 | type LogTable struct {
46 | Columns [][]string
47 | RowsCount int
48 | ColumnsCount int
49 | RowWidths []int
50 | RowSeparator string
51 | ColumnSeparator string
52 | }
53 |
54 | func NewLogTable() *LogTable {
55 | return &LogTable{
56 | Columns: [][]string{},
57 | RowsCount: 0,
58 | ColumnsCount: 0,
59 | RowWidths: []int{},
60 | RowSeparator: "\n",
61 | ColumnSeparator: " ",
62 | }
63 | }
64 |
65 | func (table *LogTable) Add(values ...string) {
66 | table.AddColumn(values)
67 | }
68 |
69 | func (table *LogTable) AddColumn(column []string) {
70 | table.Columns = append(table.Columns, column)
71 | table.RowsCount++
72 | columnCount := len(column)
73 | if columnCount > table.ColumnsCount {
74 | table.RowWidths = append(
75 | table.RowWidths,
76 | make([]int, columnCount-table.ColumnsCount)...,
77 | )
78 | table.ColumnsCount = columnCount
79 | }
80 | for i, x := range column {
81 | currWidth := len(StripAnsi(x))
82 | if currWidth > table.RowWidths[i] {
83 | table.RowWidths[i] = currWidth
84 | }
85 | }
86 | }
87 |
88 | func (table *LogTable) Build() string {
89 | var text strings.Builder
90 | for i, row := range table.Columns {
91 | isLastRow := i == table.RowsCount-1
92 | for j, x := range row {
93 | isLastColumn := j == table.ColumnsCount-1
94 | maxWidth := table.RowWidths[j]
95 | currWidth := len(StripAnsi(x))
96 | text.WriteString(x)
97 | text.WriteString(strings.Repeat(" ", maxWidth-currWidth))
98 | if !isLastColumn {
99 | text.WriteString(table.ColumnSeparator)
100 | }
101 | }
102 | if !isLastRow {
103 | text.WriteString(table.RowSeparator)
104 | }
105 | }
106 | return text.String()
107 | }
108 |
109 | func (table *LogTable) Print() {
110 | fmt.Println(table.Build())
111 | }
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Phở
2 |
3 | [](https://github.com/zyrouge/pho/releases/latest)
4 | [](https://github.com/zyrouge/pho/actions/workflows/build.yml)
5 | [](https://github.com/zyrouge/pho/actions/workflows/release.yml)
6 |
7 |
8 |

9 |
10 |
11 | ## Features
12 |
13 | - Manage AppImages by organizing them in a single folder.
14 | - Integrates AppImages seamlessly. (AppImages must follow AppImage Specification to be integrated with desktop.)
15 | - Ability to download AppImages from Github Releases and URLs.
16 | - Supports updation of AppImages. (AppImages fetched from Github Releases only.)
17 | - Configuration files can be manually edit to further customize functionality.
18 |
19 | ## Installation
20 |
21 | ### Using Script
22 |
23 | Run the below command in terminal. This installs Pho at `~/.local/bin`.
24 |
25 | ```bash
26 | curl -Ls https://raw.githubusercontent.com/zyrouge/pho/main/install.sh | bash
27 | ```
28 |
29 | If you need to install into a custom path, use the below command.
30 |
31 | ```bash
32 | # ensure that the directory exists
33 | mkdir -p /custom/directory
34 |
35 | curl -Ls https://raw.githubusercontent.com/zyrouge/pho/main/install.sh | bash -s /custom/directory
36 | ```
37 |
38 | ### Manual
39 |
40 | 1. All releases can be found [here](https://github.com/zyrouge/pho/releases). Choose a valid release.
41 | 2. Binaries are built for 32-bit/64-bit AMD and ARM separately. Download the appropriate one.
42 | 3. Rename the downloaded binary to `pho` and place it in a folder that is available in the `PATH` environmental variable. Typically this would be `~/.local/bin`.
43 | 4. Run `pho init` to setup necessary configuration.
44 | 5. Have fun! 🎉
45 |
46 | | Binary name | Platform |
47 | | ----------- | ---------- |
48 | | `pho-386` | 32-bit AMD |
49 | | `pho-amd64` | 64-bit AMD |
50 | | `pho-arm` | 32-bit ARM |
51 | | `pho-arm64` | 32-bit ARM |
52 |
53 | ## Examples
54 |
55 | - `pho init` - Initialize Pho configuration.
56 | - `pho install local ./SomeApp.AppImage` - Install and integrate a local AppImage.
57 | - `pho install github owner/repo` - Download, install and integrate an AppImage from Github Releases.
58 | - `pho update` - Update all installed AppImages.
59 | - `pho uninstall some-app` - Uninstall an AppImage.
60 |
61 | ## Developement
62 |
63 | 0. Have Install [Go](https://go.dev/) and [Git](https://git-scm.com) installed.
64 | 1. Fork and/or Clone this repository.
65 | 2. Install dependencies using `go get`.
66 | 3. Run the application using `go run main.go`.
67 | 4. Build the application using `./scripts/build.sh`.
68 |
69 | ## Contributing
70 |
71 | Any kind of contribution including creating issues or making pull requests is welcomed. Make sure to keep it pointful. Donations through [GitHub Sponsors](https://github.com/sponsors/zyrouge) or [Patreon](https://patreon.com/zyrouge) are welcomed. It helps me to stay motivated.
72 |
73 | ## License
74 |
75 | [MIT](./LICENSE)
76 |
--------------------------------------------------------------------------------
/commands/tidy-broken.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "os"
8 |
9 | "github.com/fatih/color"
10 | "github.com/urfave/cli/v3"
11 | "github.com/zyrouge/pho/core"
12 | "github.com/zyrouge/pho/utils"
13 | )
14 |
15 | var TidyBrokenCommand = cli.Command{
16 | Name: "tidy-broken",
17 | Aliases: []string{},
18 | Usage: "Remove incomplete files",
19 | Flags: []cli.Flag{
20 | &cli.BoolFlag{
21 | Name: "assume-yes",
22 | Aliases: []string{"y"},
23 | Usage: "Automatically answer yes for questions",
24 | },
25 | },
26 | Action: func(_ context.Context, cmd *cli.Command) error {
27 | utils.LogDebug("reading transactions")
28 | transactions, err := core.GetTransactions()
29 | if err != nil {
30 | return err
31 | }
32 |
33 | reader := bufio.NewReader(os.Stdin)
34 | assumeYes := cmd.Bool("assume-yes")
35 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
36 |
37 | utils.LogLn()
38 | utils.LogInfo("List of affected directories and files:")
39 | involvedIds := []string{}
40 | involvedDirs := []string{}
41 | involvedFiles := []string{}
42 | for k, v := range transactions.PendingInstallations {
43 | involvedIds = append(involvedIds, k)
44 | involvedDirs = append(involvedDirs, v.InvolvedDirs...)
45 | involvedFiles = append(involvedFiles, v.InvolvedFiles...)
46 | for _, x := range v.InvolvedDirs {
47 | utils.LogInfo(
48 | fmt.Sprintf("%s %s", color.HiBlackString("D"), color.RedString(x)),
49 | )
50 | }
51 | for _, x := range v.InvolvedFiles {
52 | utils.LogInfo(
53 | fmt.Sprintf("%s %s", color.HiBlackString("F"), color.RedString(x)),
54 | )
55 | }
56 | }
57 |
58 | if len(involvedDirs)+len(involvedFiles) == 0 {
59 | utils.LogInfo(color.HiBlackString("no directories or files are affected"))
60 | utils.LogLn()
61 | utils.LogInfo(
62 | fmt.Sprintf("%s Everything is working perfectly!", utils.LogTickPrefix),
63 | )
64 | return nil
65 | }
66 |
67 | if !assumeYes {
68 | utils.LogLn()
69 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
70 | if err != nil {
71 | return err
72 | }
73 | if !proceed {
74 | utils.LogWarning("aborted...")
75 | return nil
76 | }
77 | }
78 |
79 | removedDirsCount := 0
80 | removedFilesCount := 0
81 | utils.LogLn()
82 | for _, x := range involvedDirs {
83 | utils.LogDebug(fmt.Sprintf("removing %s", x))
84 | if err := os.RemoveAll(x); err != nil {
85 | utils.LogError(err)
86 | continue
87 | }
88 | removedDirsCount++
89 | }
90 | for _, x := range involvedFiles {
91 | utils.LogDebug(fmt.Sprintf("removing %s", x))
92 | if err := os.Remove(x); err != nil {
93 | utils.LogError(err)
94 | continue
95 | }
96 | removedFilesCount++
97 | }
98 | core.UpdateTransactions(func(transactions *core.Transactions) error {
99 | for _, x := range involvedIds {
100 | delete(transactions.PendingInstallations, x)
101 | }
102 | return nil
103 | })
104 |
105 | utils.LogLn()
106 | utils.LogInfo(
107 | fmt.Sprintf(
108 | "%s Removed %d directories and %d files successfully!",
109 | utils.LogTickPrefix,
110 | removedDirsCount,
111 | removedFilesCount,
112 | ),
113 | )
114 |
115 | return nil
116 | },
117 | }
118 |
--------------------------------------------------------------------------------
/commands/self-update.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "os"
9 | "strings"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var SelfUpdateCommand = cli.Command{
18 | Name: "self-update",
19 | Aliases: []string{"self-upgrade"},
20 | Flags: []cli.Flag{
21 | &cli.BoolFlag{
22 | Name: "reinstall",
23 | Usage: "Forcefully update",
24 | },
25 | },
26 | Usage: fmt.Sprintf("Update %s", core.AppName),
27 | Action: func(_ context.Context, cmd *cli.Command) error {
28 | reinstall := cmd.Bool("reinstall")
29 | utils.LogDebug(fmt.Sprintf("argument reinstall: %v", reinstall))
30 |
31 | utils.LogDebug("fetching latest release")
32 | release, err := core.GithubApiFetchLatestRelease(core.AppGithubOwner, core.AppGithubRepo)
33 | if err != nil {
34 | return err
35 | }
36 | if release.TagName == fmt.Sprintf("v%s", core.AppVersion) && !reinstall {
37 | utils.LogInfo(
38 | fmt.Sprintf("%s You are already on the latest version!", utils.LogTickPrefix),
39 | )
40 | return nil
41 | }
42 | arch := utils.GetSystemArch()
43 | var asset *core.GithubApiReleaseAsset
44 | for i := range release.Assets {
45 | x := release.Assets[i]
46 | if strings.HasSuffix(x.Name, arch) {
47 | asset = &x
48 | break
49 | }
50 | }
51 | if asset == nil {
52 | return fmt.Errorf(
53 | "unable to find appropriate binary from release %s",
54 | release.TagName,
55 | )
56 | }
57 |
58 | utils.LogInfo(fmt.Sprintf("Updating to version %s...", color.CyanString(release.TagName)))
59 | utils.LogDebug(fmt.Sprintf("downloading from %s", asset.DownloadUrl))
60 | data, err := http.Get(asset.DownloadUrl)
61 | if err != nil {
62 | return err
63 | }
64 | defer data.Body.Close()
65 | executablePath, err := os.Executable()
66 | if err != nil {
67 | return err
68 | }
69 | utils.LogDebug(fmt.Sprintf("current executable path as %s", executablePath))
70 | tempFile, err := utils.CreateTempFile(executablePath)
71 | if err != nil {
72 | return err
73 | }
74 | utils.LogDebug(fmt.Sprintf("created %s", tempFile.Name()))
75 | defer tempFile.Close()
76 | _, err = io.Copy(tempFile, data.Body)
77 | if err != nil {
78 | return err
79 | }
80 | utils.LogDebug(fmt.Sprintf("removing %s", executablePath))
81 | if err = os.Remove(executablePath); err != nil {
82 | return err
83 | }
84 | utils.LogDebug(fmt.Sprintf("renaming %s to %s", tempFile.Name(), executablePath))
85 | if err = os.Rename(tempFile.Name(), executablePath); err != nil {
86 | return err
87 | }
88 | utils.LogDebug(fmt.Sprintf("changing permissions of %s", executablePath))
89 | if err = os.Chmod(executablePath, 0755); err != nil {
90 | return err
91 | }
92 | utils.LogInfo(
93 | fmt.Sprintf(
94 | "%s Updated to version %s successfully!",
95 | utils.LogTickPrefix,
96 | color.CyanString(release.TagName),
97 | ),
98 | )
99 |
100 | return nil
101 | },
102 | }
103 |
104 | func needsSelfUpdate() bool {
105 | release, err := core.GithubApiFetchLatestRelease(core.AppGithubOwner, core.AppGithubRepo)
106 | if err != nil {
107 | return false
108 | }
109 | return release.TagName != fmt.Sprintf("v%s", core.AppVersion)
110 | }
111 |
--------------------------------------------------------------------------------
/core/github_api.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "regexp"
9 | "strings"
10 | )
11 |
12 | type GithubApiRelease struct {
13 | ApiUrl string `json:"url"`
14 | HtmlUrl string `json:"html_url"`
15 | TagName string `json:"tag_name"`
16 | Draft bool `json:"draft"`
17 | PreRelease bool `json:"prerelease"`
18 | Assets []GithubApiReleaseAsset `json:"assets"`
19 | }
20 |
21 | type GithubApiReleaseAsset struct {
22 | ApiUrl string `json:"url"`
23 | DownloadUrl string `json:"browser_download_url"`
24 | Name string `json:"name"`
25 | Size int64 `json:"size"`
26 | }
27 |
28 | func GithubApiFetchReleases(username string, reponame string) (*[]GithubApiRelease, error) {
29 | return RequestGithubApi[[]GithubApiRelease](
30 | "GET",
31 | fmt.Sprintf("/repos/%s/%s/releases", username, reponame),
32 | )
33 | }
34 |
35 | func GithubApiFetchLatestPreRelease(username string, reponame string) (*GithubApiRelease, error) {
36 | releases, err := GithubApiFetchReleases(username, reponame)
37 | if err != nil {
38 | return nil, err
39 | }
40 | for _, x := range *releases {
41 | if x.PreRelease {
42 | return &x, nil
43 | }
44 | }
45 | return nil, errors.New("no prerelease found")
46 | }
47 |
48 | func GithubApiFetchLatestAny(username string, reponame string) (*GithubApiRelease, error) {
49 | releases, err := GithubApiFetchReleases(username, reponame)
50 | if err != nil {
51 | return nil, err
52 | }
53 | for _, x := range *releases {
54 | if !x.Draft {
55 | return &x, nil
56 | }
57 | }
58 | return nil, errors.New("no non-draft releases found")
59 | }
60 |
61 | func GithubApiFetchLatestRelease(username string, reponame string) (*GithubApiRelease, error) {
62 | return RequestGithubApi[GithubApiRelease](
63 | "GET",
64 | fmt.Sprintf("/repos/%s/%s/releases/latest", username, reponame),
65 | )
66 | }
67 |
68 | func GithubApiFetchTaggedRelease(username string, reponame string, tag string) (*GithubApiRelease, error) {
69 | return RequestGithubApi[GithubApiRelease](
70 | "GET",
71 | fmt.Sprintf("/repos/%s/%s/releases/tags/%s", username, reponame, tag),
72 | )
73 | }
74 |
75 | func (asset *GithubApiReleaseAsset) ToAsset() *Asset {
76 | return &Asset{
77 | Source: asset.DownloadUrl,
78 | Size: asset.Size,
79 | Download: NetworkAssetDownload(asset.DownloadUrl),
80 | }
81 | }
82 |
83 | var GithubRepoUrlRegex = regexp.MustCompile(`^([^\/]+)\/([^\/]+)$`)
84 |
85 | func ParseGithubRepoUrl(url string) (bool, string, string) {
86 | url = strings.TrimPrefix(url, "https://github.com/")
87 | matches := GithubRepoUrlRegex.FindStringSubmatch(url)
88 | if matches == nil {
89 | return false, "", ""
90 | }
91 | return true, matches[1], matches[2]
92 | }
93 |
94 | func RequestGithubApi[T any](method string, route string) (*T, error) {
95 | url := fmt.Sprintf("https://api.github.com%s", route)
96 | req, err := http.NewRequest("GET", url, nil)
97 | if err != nil {
98 | return nil, err
99 | }
100 | req.Header.Set("Accept", "application/vnd.github+json")
101 | res, err := http.DefaultClient.Do(req)
102 | if err != nil {
103 | return nil, err
104 | }
105 | defer res.Body.Close()
106 | if res.StatusCode != 200 {
107 | return nil, fmt.Errorf(
108 | "github api response returned status %d with message \"%s\"",
109 | res.StatusCode,
110 | res.Status,
111 | )
112 | }
113 | output := new(T)
114 | decoder := json.NewDecoder(res.Body)
115 | err = decoder.Decode(output)
116 | if err != nil {
117 | return nil, err
118 | }
119 | return output, nil
120 | }
121 |
--------------------------------------------------------------------------------
/commands/app-config-set-id.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "path"
9 |
10 | "github.com/fatih/color"
11 | "github.com/urfave/cli/v3"
12 | "github.com/zyrouge/pho/core"
13 | "github.com/zyrouge/pho/utils"
14 | )
15 |
16 | var AppConfigSetIdCommand = cli.Command{
17 | Name: "set-id",
18 | Usage: "Update an application's identifier",
19 | Action: func(_ context.Context, cmd *cli.Command) error {
20 | utils.LogDebug("reading config")
21 | config, err := core.GetConfig()
22 | if err != nil {
23 | return err
24 | }
25 |
26 | args := cmd.Args()
27 | if args.Len() == 0 {
28 | return errors.New("no application id specified")
29 | }
30 | if args.Len() == 1 {
31 | return errors.New("no new application id specified")
32 | }
33 | if args.Len() > 2 {
34 | return errors.New("unexpected excessive arguments")
35 | }
36 |
37 | fromAppId := args.Get(0)
38 | toAppId := args.Get(1)
39 | utils.LogDebug(fmt.Sprintf("argument from-id: %v", fromAppId))
40 | utils.LogDebug(fmt.Sprintf("argument to-id: %v", toAppId))
41 |
42 | if _, ok := config.Installed[fromAppId]; !ok {
43 | return fmt.Errorf(
44 | "application with id %s is not installed",
45 | color.CyanString(fromAppId),
46 | )
47 | }
48 |
49 | toAppId = core.ConstructAppId(toAppId)
50 | utils.LogDebug(fmt.Sprintf("clean to-id: %v", toAppId))
51 | if toAppId == "" {
52 | return errors.New("invalid application id")
53 | }
54 |
55 | fromAppConfigPath := core.GetAppConfigPath(config, fromAppId)
56 | utils.LogDebug(fmt.Sprintf("reading app config from %s", fromAppConfigPath))
57 | app, err := core.ReadAppConfig(fromAppConfigPath)
58 | if err != nil {
59 | return err
60 | }
61 | fromAppPaths := app.Paths
62 | toAppPaths := core.ConstructAppPaths(config, toAppId, &core.ConstructAppPathsOptions{
63 | Symlink: fromAppPaths.Symlink != "",
64 | })
65 | app.Id = toAppId
66 | app.Paths = *toAppPaths
67 | delete(config.Installed, fromAppId)
68 | config.Installed[toAppId] = toAppPaths.Config
69 | utils.LogDebug(fmt.Sprintf("moving from %s to %s", fromAppPaths.Dir, toAppPaths.Dir))
70 | if err = os.Rename(fromAppPaths.Dir, toAppPaths.Dir); err != nil {
71 | return err
72 | }
73 | fromAppImagePath := path.Join(toAppPaths.Dir, path.Base(fromAppPaths.AppImage))
74 | if err = os.Rename(fromAppImagePath, toAppPaths.AppImage); err != nil {
75 | return err
76 | }
77 | fromIconPath := path.Join(toAppPaths.Dir, path.Base(fromAppPaths.Icon))
78 | if err = os.Rename(fromIconPath, toAppPaths.Icon); err != nil {
79 | return err
80 | }
81 | utils.LogDebug(fmt.Sprintf("saving app config to %s", toAppPaths.Config))
82 | if err = core.SaveAppConfig(toAppPaths.Config, app); err != nil {
83 | return err
84 | }
85 | utils.LogDebug("saving config")
86 | if err = core.SaveConfig(config); err != nil {
87 | return err
88 | }
89 | utils.LogDebug(fmt.Sprintf("reading .desktop file at %s", fromAppPaths.Desktop))
90 | desktopContent, err := os.ReadFile(fromAppPaths.Desktop)
91 | if err != nil {
92 | return err
93 | }
94 | utils.LogDebug(fmt.Sprintf("uninstalling .desktop file at %s", fromAppPaths.Desktop))
95 | if err = core.UninstallDesktopFile(fromAppPaths.Desktop); err != nil {
96 | return err
97 | }
98 | utils.LogDebug(fmt.Sprintf("installing .desktop file at %s", fromAppPaths.Desktop))
99 | if err = core.InstallDesktopFile(toAppPaths, string(desktopContent)); err != nil {
100 | return err
101 | }
102 | if toAppPaths.Symlink != "" {
103 | utils.LogDebug(fmt.Sprintf("removing symlink at %s", fromAppPaths.Symlink))
104 | if err = os.Remove(fromAppPaths.Symlink); err != nil {
105 | return err
106 | }
107 | utils.LogDebug(fmt.Sprintf("creating symlink at %s", toAppPaths.Symlink))
108 | if err = os.Symlink(toAppPaths.AppImage, toAppPaths.Symlink); err != nil {
109 | return err
110 | }
111 | }
112 |
113 | utils.LogLn()
114 | utils.LogInfo(
115 | fmt.Sprintf(
116 | "%s Renamed %s to %s successfully!",
117 | utils.LogTickPrefix,
118 | color.CyanString(fromAppId),
119 | color.CyanString(toAppId),
120 | ),
121 | )
122 |
123 | return nil
124 | },
125 | }
126 |
--------------------------------------------------------------------------------
/commands/uninstall.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "strings"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var UninstallCommand = cli.Command{
18 | Name: "uninstall",
19 | Aliases: []string{"remove", "delete"},
20 | Usage: "Uninstall an application",
21 | Flags: []cli.Flag{
22 | &cli.BoolFlag{
23 | Name: "assume-yes",
24 | Aliases: []string{"y"},
25 | Usage: "Automatically answer yes for questions",
26 | },
27 | },
28 | Action: func(_ context.Context, cmd *cli.Command) error {
29 | config, err := core.GetConfig()
30 | if err != nil {
31 | return err
32 | }
33 |
34 | reader := bufio.NewReader(os.Stdin)
35 | args := cmd.Args()
36 | if args.Len() == 0 {
37 | return errors.New("no application ids specified")
38 | }
39 |
40 | appIds := args.Slice()
41 | assumeYes := cmd.Bool("assume-yes")
42 | utils.LogDebug(fmt.Sprintf("argument ids: %s", strings.Join(appIds, ", ")))
43 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
44 |
45 | utils.LogLn()
46 | failed := 0
47 | uninstallables := []core.AppConfig{}
48 | for _, appId := range appIds {
49 | if _, ok := config.Installed[appId]; !ok {
50 | failed++
51 | utils.LogError(
52 | fmt.Sprintf(
53 | "application with id %s is not installed",
54 | color.CyanString(appId),
55 | ),
56 | )
57 | continue
58 | }
59 | appConfigPath := core.GetAppConfigPath(config, appId)
60 | utils.LogDebug(fmt.Sprintf("reading app config from %s", appConfigPath))
61 | app, err := core.ReadAppConfig(appConfigPath)
62 | if err != nil {
63 | failed++
64 | utils.LogError(err)
65 | continue
66 | }
67 | uninstallables = append(uninstallables, *app)
68 | }
69 | if len(uninstallables) == 0 {
70 | return nil
71 | }
72 | if failed > 0 {
73 | utils.LogLn()
74 | }
75 |
76 | summary := utils.NewLogTable()
77 | headingColor := color.New(color.Underline, color.Bold)
78 | summary.Add(
79 | headingColor.Sprint("Index"),
80 | headingColor.Sprint("Application ID"),
81 | headingColor.Sprint("Version"),
82 | )
83 | i := 0
84 | for _, x := range uninstallables {
85 | i++
86 | summary.Add(
87 | fmt.Sprintf("%d.", i),
88 | color.RedString(x.Id),
89 | x.Version,
90 | )
91 | }
92 | summary.Print()
93 |
94 | if !assumeYes {
95 | utils.LogLn()
96 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
97 | if err != nil {
98 | return err
99 | }
100 | if !proceed {
101 | utils.LogWarning("aborted...")
102 | return nil
103 | }
104 | }
105 |
106 | utils.LogLn()
107 | failed = 0
108 | for _, x := range uninstallables {
109 | failed += UninstallApp(&x)
110 | }
111 | if failed > 0 {
112 | utils.LogLn()
113 | utils.LogInfo(
114 | fmt.Sprintf(
115 | "%s Uninstalled %s applications with %s errors.",
116 | utils.LogTickPrefix,
117 | color.RedString(fmt.Sprint(len(uninstallables))),
118 | color.RedString(fmt.Sprint(failed)),
119 | ),
120 | )
121 | } else {
122 | utils.LogInfo(
123 | fmt.Sprintf(
124 | "%s Uninstalled %s applications successfully!",
125 | utils.LogTickPrefix,
126 | color.RedString(fmt.Sprint(len(uninstallables))),
127 | ),
128 | )
129 | }
130 |
131 | return nil
132 | },
133 | }
134 |
135 | func UninstallApp(app *core.AppConfig) int {
136 | failed := 0
137 | utils.LogDebug("reading config")
138 | config, err := core.ReadConfig()
139 | if err != nil {
140 | utils.LogError(err)
141 | failed++
142 | } else {
143 | delete(config.Installed, app.Id)
144 | utils.LogDebug("saving config")
145 | if err = core.SaveConfig(config); err != nil {
146 | utils.LogError(err)
147 | failed++
148 | }
149 | }
150 | utils.LogDebug(fmt.Sprintf("removing %s", app.Paths.Dir))
151 | if err = os.RemoveAll(app.Paths.Dir); err != nil {
152 | utils.LogError(err)
153 | failed++
154 | }
155 | utils.LogDebug(fmt.Sprintf("removing %s", app.Paths.Desktop))
156 | if err = core.UninstallDesktopFile(app.Paths.Desktop); err != nil {
157 | utils.LogError(err)
158 | failed++
159 | }
160 | if app.Paths.Symlink != "" {
161 | utils.LogDebug(fmt.Sprintf("removing %s", app.Paths.Symlink))
162 | if err = os.Remove(app.Paths.Symlink); err != nil {
163 | utils.LogError(err)
164 | failed++
165 | }
166 | }
167 | return failed
168 | }
169 |
--------------------------------------------------------------------------------
/core/appimage.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | "os"
8 | "os/exec"
9 | "path"
10 | "regexp"
11 | "strings"
12 |
13 | "github.com/zyrouge/pho/utils"
14 | )
15 |
16 | type DeflatedAppImage struct {
17 | AppImagePath string
18 | ParentDir string
19 | AppDir string
20 | }
21 |
22 | func DeflateAppImage(appImagePath string, parentDir string) (*DeflatedAppImage, error) {
23 | cmd := exec.Command(appImagePath, "--appimage-extract")
24 | cmd.Dir = parentDir
25 | if err := cmd.Run(); err != nil {
26 | return nil, err
27 | }
28 | appDir := path.Join(parentDir, "squashfs-root")
29 | deflated := &DeflatedAppImage{
30 | AppImagePath: appImagePath,
31 | ParentDir: parentDir,
32 | AppDir: appDir,
33 | }
34 | return deflated, nil
35 | }
36 |
37 | type DeflatedAppImageMetadata struct {
38 | *DeflatedAppImage
39 | ExecName string
40 | IconPath string
41 | DesktopPath string
42 | }
43 |
44 | func (deflated *DeflatedAppImage) ExtractMetadata() (*DeflatedAppImageMetadata, error) {
45 | execName, err := deflated.ExtractExecName()
46 | if err != nil {
47 | return nil, err
48 | }
49 | desktopPath := path.Join(deflated.AppDir, fmt.Sprintf("%s.desktop", execName))
50 | _, iconPath := utils.FindFileInDir(deflated.AppDir, []string{
51 | ".DirIcon",
52 | fmt.Sprintf("%s.png", execName),
53 | fmt.Sprintf("%s.jpg", execName),
54 | })
55 | metadata := &DeflatedAppImageMetadata{
56 | DeflatedAppImage: deflated,
57 | ExecName: execName,
58 | IconPath: iconPath,
59 | DesktopPath: desktopPath,
60 | }
61 | return metadata, nil
62 | }
63 |
64 | func (deflated *DeflatedAppImage) ExtractExecName() (string, error) {
65 | files, err := os.ReadDir(deflated.AppDir)
66 | if err != nil {
67 | return "", err
68 | }
69 | for _, x := range files {
70 | name := x.Name()
71 | if strings.HasSuffix(name, ".desktop") {
72 | return strings.TrimSuffix(name, ".desktop"), nil
73 | }
74 | }
75 | return "", errors.New("cannot find .desktop file from AppDir")
76 | }
77 |
78 | func (metadata *DeflatedAppImageMetadata) CopyIconFile(paths *AppPaths) error {
79 | if metadata.IconPath == "" {
80 | return nil
81 | }
82 | src, err := os.Open(metadata.IconPath)
83 | if err != nil {
84 | return err
85 | }
86 | defer src.Close()
87 | dest, err := os.Create(paths.Icon)
88 | if err != nil {
89 | return err
90 | }
91 | defer dest.Close()
92 | _, err = io.Copy(dest, src)
93 | return err
94 | }
95 |
96 | var desktopFileExecRegex = regexp.MustCompile(`Exec=("[^"]+"|[^\s]+)`)
97 | var desktopFileIconRegex = regexp.MustCompile(`Icon=[^\n]+`)
98 |
99 | func (metadata *DeflatedAppImageMetadata) InstallDesktopFile(paths *AppPaths) error {
100 | bytes, err := os.ReadFile(metadata.DesktopPath)
101 | if err != nil {
102 | return err
103 | }
104 | return InstallDesktopFile(paths, string(bytes))
105 | }
106 |
107 | func InstallDesktopFile(paths *AppPaths, content string) error {
108 | config, err := ReadConfig()
109 | if err != nil {
110 | return err
111 | }
112 | execPath := utils.QuotedWhenSpace(paths.AppImage)
113 | if !config.EnableIntegrationPrompt {
114 | execPath = "env APPIMAGELAUNCHER_DISABLE=1 " + execPath
115 | }
116 | content = replaceDesktopEntry(
117 | content,
118 | desktopFileExecRegex,
119 | fmt.Sprintf("Exec=%s", execPath),
120 | )
121 | content = replaceDesktopEntry(
122 | content,
123 | desktopFileIconRegex,
124 | fmt.Sprintf("Icon=%s", utils.QuotedWhenSpace(paths.Icon)),
125 | )
126 | content = strings.TrimSpace(content)
127 | if err := os.WriteFile(paths.Desktop, []byte(content), os.ModePerm); err != nil {
128 | return err
129 | }
130 | cmd := exec.Command("xdg-desktop-menu", "install", paths.Desktop, "--novendor")
131 | return cmd.Run()
132 | }
133 |
134 | func UninstallDesktopFile(desktopFilePath string) error {
135 | cmd := exec.Command("xdg-desktop-menu", "uninstall", desktopFilePath, "--novendor")
136 | return cmd.Run()
137 | }
138 |
139 | func (metadata *DeflatedAppImageMetadata) Symlink(paths *AppPaths) error {
140 | if err := os.Symlink(paths.AppImage, paths.Symlink); err != nil {
141 | return err
142 | }
143 | return nil
144 | }
145 |
146 | func replaceDesktopEntry(content string, pattern *regexp.Regexp, replaceWith string) string {
147 | count := 0
148 | content = pattern.ReplaceAllStringFunc(content, func(s string) string {
149 | count++
150 | return replaceWith
151 | })
152 | if count == 0 {
153 | if !strings.HasSuffix(content, "\n") {
154 | content += "\n"
155 | }
156 | content += replaceWith
157 | }
158 | return content
159 | }
160 |
--------------------------------------------------------------------------------
/commands/install_http.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "path"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var InstallHttpCommand = cli.Command{
18 | Name: "http",
19 | Aliases: []string{"network", "from-url"},
20 | Usage: "Install AppImage from http url",
21 | Flags: []cli.Flag{
22 | &cli.StringFlag{
23 | Name: "id",
24 | Usage: "Application identifier",
25 | },
26 | &cli.StringFlag{
27 | Name: "version",
28 | Usage: "Application version",
29 | },
30 | &cli.BoolFlag{
31 | Name: "link",
32 | Aliases: []string{"l"},
33 | Usage: "Creates a symlink",
34 | },
35 | &cli.BoolFlag{
36 | Name: "assume-yes",
37 | Aliases: []string{"y"},
38 | Usage: "Automatically answer yes for questions",
39 | },
40 | },
41 | Action: func(_ context.Context, cmd *cli.Command) error {
42 | utils.LogDebug("reading config")
43 | config, err := core.GetConfig()
44 | if err != nil {
45 | return err
46 | }
47 |
48 | reader := bufio.NewReader(os.Stdin)
49 | args := cmd.Args()
50 | if args.Len() == 0 {
51 | return errors.New("no url specified")
52 | }
53 | if args.Len() > 1 {
54 | return errors.New("unexpected excessive arguments")
55 | }
56 |
57 | url := args.Get(0)
58 | appId := cmd.String("id")
59 | appVersion := cmd.String("version")
60 | link := cmd.Bool("link")
61 | assumeYes := cmd.Bool("assume-yes")
62 | utils.LogDebug(fmt.Sprintf("argument url: %s", url))
63 | utils.LogDebug(fmt.Sprintf("argument id: %s", appId))
64 | utils.LogDebug(fmt.Sprintf("argument link: %v", link))
65 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
66 |
67 | if url == "" {
68 | return errors.New("invalid url")
69 | }
70 |
71 | if appId == "" {
72 | appId = core.ConstructAppId(path.Base(url))
73 | if !assumeYes {
74 | appId, err = utils.PromptTextInput(
75 | reader,
76 | "What should be the Application ID?",
77 | appId,
78 | )
79 | if err != nil {
80 | return err
81 | }
82 | }
83 | }
84 | appId = utils.CleanId(appId)
85 | utils.LogDebug(fmt.Sprintf("clean id: %s", appId))
86 | if appId == "" {
87 | return errors.New("invalid application id")
88 | }
89 |
90 | if appVersion == "" {
91 | appVersion = "0.0.0"
92 | }
93 |
94 | appPaths := core.ConstructAppPaths(config, appId, &core.ConstructAppPathsOptions{
95 | Symlink: link,
96 | })
97 | if _, ok := config.Installed[appId]; ok {
98 | utils.LogWarning(
99 | fmt.Sprintf(
100 | "application with id %s already exists",
101 | color.CyanString(appId),
102 | ),
103 | )
104 | if !assumeYes {
105 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to re-install this application?")
106 | if err != nil {
107 | return err
108 | }
109 | if !proceed {
110 | utils.LogWarning("aborted...")
111 | return nil
112 | }
113 | }
114 | }
115 |
116 | utils.LogLn()
117 | summary := utils.NewLogTable()
118 | summary.Add(utils.LogRightArrowPrefix, "Identifier", color.CyanString(appId))
119 | summary.Add(utils.LogRightArrowPrefix, "Version", color.CyanString(appVersion))
120 | summary.Add(utils.LogRightArrowPrefix, "AppImage", color.CyanString(appPaths.AppImage))
121 | summary.Add(utils.LogRightArrowPrefix, ".desktop file", color.CyanString(appPaths.Desktop))
122 | if appPaths.Symlink != "" {
123 | summary.Add(utils.LogRightArrowPrefix, "Symlink", color.CyanString(appPaths.Symlink))
124 | }
125 | summary.Print()
126 |
127 | if !assumeYes {
128 | utils.LogLn()
129 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
130 | if err != nil {
131 | return err
132 | }
133 | if !proceed {
134 | utils.LogWarning("aborted...")
135 | return nil
136 | }
137 | }
138 |
139 | assetMetadata, err := core.ExtractNetworkAssetMetadata(url)
140 | if err != nil {
141 | return err
142 | }
143 |
144 | app := &core.AppConfig{
145 | Id: appId,
146 | Version: appVersion,
147 | Source: core.HttpSourceId,
148 | Paths: *appPaths,
149 | }
150 | source := &core.HttpSource{}
151 | asset := &core.Asset{
152 | Source: url,
153 | Size: assetMetadata.Size,
154 | Download: core.NetworkAssetDownload(url),
155 | }
156 |
157 | utils.LogLn()
158 | installed, _ := InstallApps([]InstallableApp{{
159 | App: app,
160 | Source: source,
161 | Asset: asset,
162 | }})
163 | if installed != 1 {
164 | return nil
165 | }
166 |
167 | utils.LogLn()
168 | utils.LogInfo(
169 | fmt.Sprintf(
170 | "%s Installed %s successfully!",
171 | utils.LogTickPrefix,
172 | color.CyanString(app.Id),
173 | ),
174 | )
175 |
176 | return nil
177 | },
178 | }
179 |
--------------------------------------------------------------------------------
/commands/install_local.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "path"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var InstallLocalCommand = cli.Command{
18 | Name: "local",
19 | Usage: "Install local AppImage",
20 | Flags: []cli.Flag{
21 | &cli.StringFlag{
22 | Name: "id",
23 | Usage: "Application identifier",
24 | },
25 | &cli.StringFlag{
26 | Name: "version",
27 | Usage: "Application version",
28 | },
29 | &cli.BoolFlag{
30 | Name: "link",
31 | Aliases: []string{"l"},
32 | Usage: "Creates a symlink",
33 | },
34 | &cli.BoolFlag{
35 | Name: "assume-yes",
36 | Aliases: []string{"y"},
37 | Usage: "Automatically answer yes for questions",
38 | },
39 | },
40 | Action: func(_ context.Context, cmd *cli.Command) error {
41 | utils.LogDebug("reading config")
42 | config, err := core.GetConfig()
43 | if err != nil {
44 | return err
45 | }
46 |
47 | reader := bufio.NewReader(os.Stdin)
48 | args := cmd.Args()
49 | if args.Len() == 0 {
50 | return errors.New("no url specified")
51 | }
52 | if args.Len() > 1 {
53 | return errors.New("unexpected excessive arguments")
54 | }
55 |
56 | appImagePath := args.Get(0)
57 | appId := cmd.String("id")
58 | appVersion := cmd.String("version")
59 | link := cmd.Bool("link")
60 | assumeYes := cmd.Bool("assume-yes")
61 | utils.LogDebug(fmt.Sprintf("argument path: %s", appImagePath))
62 | utils.LogDebug(fmt.Sprintf("argument id: %s", appId))
63 | utils.LogDebug(fmt.Sprintf("argument link: %v", link))
64 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
65 |
66 | if appImagePath == "" {
67 | return errors.New("invalid appimage path")
68 | }
69 | if !path.IsAbs(appImagePath) {
70 | cwd, err := os.Getwd()
71 | if err != nil {
72 | return err
73 | }
74 | appImagePath = path.Join(cwd, appImagePath)
75 | }
76 | utils.LogDebug(fmt.Sprintf("resolved appimage path: %s", appImagePath))
77 | appImageFileInfo, err := os.Stat(appImagePath)
78 | if err != nil {
79 | return err
80 | }
81 | if appImageFileInfo.IsDir() {
82 | return errors.New("appimage path must be a file")
83 | }
84 |
85 | if appId == "" {
86 | appId = core.ConstructAppId(path.Base(appImagePath))
87 | if !assumeYes {
88 | appId, err = utils.PromptTextInput(
89 | reader,
90 | "What should be the Application ID?",
91 | appId,
92 | )
93 | if err != nil {
94 | return err
95 | }
96 | }
97 | }
98 | appId = utils.CleanId(appId)
99 | utils.LogDebug(fmt.Sprintf("clean id: %s", appId))
100 | if appId == "" {
101 | return errors.New("invalid application id")
102 | }
103 |
104 | if appVersion == "" {
105 | appVersion = "0.0.0"
106 | }
107 |
108 | appPaths := core.ConstructAppPaths(config, appId, &core.ConstructAppPathsOptions{
109 | Symlink: link,
110 | })
111 | if _, ok := config.Installed[appId]; ok {
112 | utils.LogWarning(
113 | fmt.Sprintf(
114 | "application with id %s already exists",
115 | color.CyanString(appId),
116 | ),
117 | )
118 | if !assumeYes {
119 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to re-install this application?")
120 | if err != nil {
121 | return err
122 | }
123 | if !proceed {
124 | utils.LogWarning("aborted...")
125 | return nil
126 | }
127 | }
128 | }
129 |
130 | utils.LogLn()
131 | summary := utils.NewLogTable()
132 | summary.Add(utils.LogRightArrowPrefix, "Identifier", color.CyanString(appId))
133 | summary.Add(utils.LogRightArrowPrefix, "Version", color.CyanString(appVersion))
134 | summary.Add(utils.LogRightArrowPrefix, "AppImage", color.CyanString(appPaths.AppImage))
135 | summary.Add(utils.LogRightArrowPrefix, ".desktop file", color.CyanString(appPaths.Desktop))
136 | if appPaths.Symlink != "" {
137 | summary.Add(utils.LogRightArrowPrefix, "Symlink", color.CyanString(appPaths.Symlink))
138 | }
139 | summary.Print()
140 |
141 | if !assumeYes {
142 | utils.LogLn()
143 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
144 | if err != nil {
145 | return err
146 | }
147 | if !proceed {
148 | utils.LogWarning("aborted...")
149 | return nil
150 | }
151 | }
152 |
153 | app := &core.AppConfig{
154 | Id: appId,
155 | Version: appVersion,
156 | Source: core.LocalSourceId,
157 | Paths: *appPaths,
158 | }
159 | source := &core.LocalSource{}
160 | asset := &core.Asset{
161 | Source: appImagePath,
162 | Size: appImageFileInfo.Size(),
163 | Download: core.LocalAssetDownload(appImagePath),
164 | }
165 |
166 | utils.LogLn()
167 | installed, _ := InstallApps([]InstallableApp{{
168 | App: app,
169 | Source: source,
170 | Asset: asset,
171 | }})
172 | if installed != 1 {
173 | return nil
174 | }
175 |
176 | utils.LogLn()
177 | utils.LogInfo(
178 | fmt.Sprintf(
179 | "%s Installed %s successfully!",
180 | utils.LogTickPrefix,
181 | color.CyanString(app.Id),
182 | ),
183 | )
184 |
185 | return nil
186 | },
187 | }
188 |
--------------------------------------------------------------------------------
/commands/update.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "os"
8 | "strings"
9 |
10 | "github.com/fatih/color"
11 | "github.com/urfave/cli/v3"
12 | "github.com/zyrouge/pho/core"
13 | "github.com/zyrouge/pho/utils"
14 | )
15 |
16 | var UpdateCommand = cli.Command{
17 | Name: "update",
18 | Aliases: []string{"upgrade"},
19 | Usage: "Update an application",
20 | Flags: []cli.Flag{
21 | &cli.BoolFlag{
22 | Name: "assume-yes",
23 | Aliases: []string{"y"},
24 | Usage: "Automatically answer yes for questions",
25 | },
26 | &cli.BoolFlag{
27 | Name: "reinstall",
28 | Usage: "Forcefully update and reinstall application",
29 | },
30 | },
31 | Action: func(_ context.Context, cmd *cli.Command) error {
32 | utils.LogDebug("reading config")
33 | config, err := core.GetConfig()
34 | if err != nil {
35 | return err
36 | }
37 |
38 | reader := bufio.NewReader(os.Stdin)
39 | args := cmd.Args()
40 |
41 | appIds := args.Slice()
42 | assumeYes := cmd.Bool("assume-yes")
43 | reinstall := cmd.Bool("reinstall")
44 | utils.LogDebug(fmt.Sprintf("argument ids: %s", strings.Join(appIds, ", ")))
45 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
46 | utils.LogDebug(fmt.Sprintf("argument reinstall: %v", reinstall))
47 |
48 | utils.LogDebug("check for self update")
49 | if needsSelfUpdate() {
50 | utils.LogInfo(
51 | fmt.Sprintf(
52 | "New version of %s is available! Use %s to update.",
53 | color.CyanString(core.AppName),
54 | color.CyanString(fmt.Sprintf("%s self-update", core.AppExecutableName)),
55 | ),
56 | )
57 | }
58 |
59 | if len(appIds) == 0 {
60 | for x := range config.Installed {
61 | appIds = append(appIds, x)
62 | }
63 | }
64 |
65 | updateables, _, err := CheckAppUpdates(config, appIds, reinstall)
66 | if err != nil {
67 | return err
68 | }
69 | if len(updateables) == 0 {
70 | utils.LogLn()
71 | utils.LogInfo(
72 | fmt.Sprintf(
73 | "%s Everything is up-to-date.",
74 | utils.LogTickPrefix,
75 | ),
76 | )
77 | return nil
78 | }
79 |
80 | utils.LogLn()
81 | summary := utils.NewLogTable()
82 | headingColor := color.New(color.Underline, color.Bold)
83 | summary.Add(
84 | headingColor.Sprint("Index"),
85 | headingColor.Sprint("Application ID"),
86 | headingColor.Sprint("Old Version"),
87 | headingColor.Sprint("New Version"),
88 | )
89 | i := 0
90 | for _, x := range updateables {
91 | i++
92 | summary.Add(
93 | fmt.Sprintf("%d.", i),
94 | color.CyanString(x.App.Id),
95 | x.App.Version,
96 | color.CyanString(x.Update.Version),
97 | )
98 | }
99 | summary.Print()
100 |
101 | if !assumeYes {
102 | utils.LogLn()
103 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
104 | if err != nil {
105 | return err
106 | }
107 | if !proceed {
108 | utils.LogWarning("aborted...")
109 | return nil
110 | }
111 | }
112 |
113 | utils.LogLn()
114 | installables := []InstallableApp{}
115 | for _, x := range updateables {
116 | x.App.Version = x.Update.Version
117 | installables = append(installables, InstallableApp{
118 | App: x.App,
119 | Source: x.Source,
120 | Asset: x.Update.Asset,
121 | })
122 | }
123 | installed, failed := InstallApps(installables)
124 |
125 | utils.LogLn()
126 | if installed > 0 {
127 | utils.LogInfo(
128 | fmt.Sprintf(
129 | "%s Updated %s applications successfully!",
130 | utils.LogTickPrefix,
131 | color.CyanString(fmt.Sprint(installed)),
132 | ),
133 | )
134 | }
135 | if failed > 0 {
136 | utils.LogInfo(
137 | fmt.Sprintf(
138 | "%s Failed to update %s applications.",
139 | utils.LogExclamationPrefix,
140 | color.RedString(fmt.Sprint(failed)),
141 | ),
142 | )
143 | }
144 |
145 | return nil
146 | },
147 | }
148 |
149 | type UpdatableApp struct {
150 | App *core.AppConfig
151 | Source any
152 | Update *core.SourceUpdate
153 | }
154 |
155 | func CheckAppUpdates(config *core.Config, appIds []string, reinstall bool) ([]UpdatableApp, int, error) {
156 | failed := 0
157 | apps := []UpdatableApp{}
158 | for _, appId := range appIds {
159 | updatable, err := CheckAppUpdate(config, appId, reinstall)
160 | if err != nil {
161 | failed++
162 | utils.LogError(err)
163 | continue
164 | }
165 | if updatable != nil {
166 | apps = append(apps, *updatable)
167 | }
168 | }
169 | return apps, failed, nil
170 | }
171 |
172 | func CheckAppUpdate(config *core.Config, appId string, reinstall bool) (*UpdatableApp, error) {
173 | if _, ok := config.Installed[appId]; !ok {
174 | return nil, fmt.Errorf(
175 | "application with id %s is not installed",
176 | color.CyanString(appId),
177 | )
178 | }
179 | appConfigPath := core.GetAppConfigPath(config, appId)
180 | utils.LogDebug(fmt.Sprintf("reading app config from %s", appConfigPath))
181 | app, err := core.ReadAppConfig(appConfigPath)
182 | if err != nil {
183 | return nil, err
184 | }
185 | utils.LogDebug(fmt.Sprintf("reading app source config from %s", app.Paths.SourceConfig))
186 | sourceConfig, err := core.ReadSourceConfig(app.Source, app.Paths.SourceConfig)
187 | if err != nil {
188 | return nil, err
189 | }
190 | source, err := core.CastSourceConfigAsSource(sourceConfig)
191 | if err != nil {
192 | return nil, err
193 | }
194 | if !source.SupportUpdates() {
195 | utils.LogDebug(fmt.Sprintf("%s doesnt support any updates", appId))
196 | return nil, nil
197 | }
198 | update, err := source.CheckUpdate(app, reinstall)
199 | if err != nil {
200 | return nil, err
201 | }
202 | if update == nil {
203 | utils.LogDebug(fmt.Sprintf("%s has no updates", appId))
204 | return nil, nil
205 | }
206 | updatable := &UpdatableApp{
207 | App: app,
208 | Source: sourceConfig,
209 | Update: update,
210 | }
211 | return updatable, nil
212 | }
213 |
--------------------------------------------------------------------------------
/commands/install_github.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "strings"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var githubSourceReleaseStrings = []string{
18 | string(core.GithubSourceReleaseLatest),
19 | string(core.GithubSourceReleasePreRelease),
20 | string(core.GithubSourceReleaseTagged),
21 | string(core.GithubSourceReleaseAny),
22 | }
23 |
24 | var InstallGithubCommand = cli.Command{
25 | Name: "github",
26 | Aliases: []string{"gh"},
27 | Usage: "Install an application from Github",
28 | Flags: []cli.Flag{
29 | &cli.StringFlag{
30 | Name: "id",
31 | Usage: "Application identifier",
32 | },
33 | &cli.StringFlag{
34 | Name: "release",
35 | Aliases: []string{"r"},
36 | Usage: fmt.Sprintf(
37 | "Releases type such as %s",
38 | strings.Join(githubSourceReleaseStrings, ", "),
39 | ),
40 | Value: githubSourceReleaseStrings[0],
41 | },
42 | &cli.StringFlag{
43 | Name: "tag",
44 | Aliases: []string{"t"},
45 | Usage: fmt.Sprintf(
46 | "Release tag name (requires release to be %s)",
47 | core.GithubSourceReleaseTagged,
48 | ),
49 | },
50 | &cli.BoolFlag{
51 | Name: "link",
52 | Aliases: []string{"l"},
53 | Usage: "Creates a symlink",
54 | },
55 | &cli.BoolFlag{
56 | Name: "assume-yes",
57 | Aliases: []string{"y"},
58 | Usage: "Automatically answer yes for questions",
59 | },
60 | },
61 | Action: func(_ context.Context, cmd *cli.Command) error {
62 | utils.LogDebug("reading config")
63 | config, err := core.GetConfig()
64 | if err != nil {
65 | return err
66 | }
67 |
68 | reader := bufio.NewReader(os.Stdin)
69 | args := cmd.Args()
70 | if args.Len() == 0 {
71 | return errors.New("no url specified")
72 | }
73 | if args.Len() > 1 {
74 | return errors.New("unexpected excessive arguments")
75 | }
76 |
77 | url := args.Get(0)
78 | appId := cmd.String("id")
79 | releaseType := cmd.String("release")
80 | tagName := cmd.String("tag")
81 | link := cmd.Bool("link")
82 | assumeYes := cmd.Bool("assume-yes")
83 | utils.LogDebug(fmt.Sprintf("argument url: %s", url))
84 | utils.LogDebug(fmt.Sprintf("argument id: %s", appId))
85 | utils.LogDebug(fmt.Sprintf("argument release: %v", releaseType))
86 | utils.LogDebug(fmt.Sprintf("argument tag: %v", tagName))
87 | utils.LogDebug(fmt.Sprintf("argument link: %v", link))
88 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
89 |
90 | isValidUrl, ghUsername, ghReponame := core.ParseGithubRepoUrl(url)
91 | utils.LogDebug(fmt.Sprintf("parsed github url valid: %v", isValidUrl))
92 | utils.LogDebug(fmt.Sprintf("parsed github owner: %s", ghUsername))
93 | utils.LogDebug(fmt.Sprintf("parsed github repo: %s", ghReponame))
94 | if !isValidUrl {
95 | return errors.New("invalid github repo url")
96 | }
97 | if !utils.SliceContains(githubSourceReleaseStrings, releaseType) {
98 | return errors.New("invalid github release type")
99 | }
100 |
101 | if appId == "" {
102 | appId = core.ConstructAppId(ghReponame)
103 | }
104 | appId = utils.CleanId(appId)
105 | utils.LogDebug(fmt.Sprintf("clean id: %s", appId))
106 | if appId == "" {
107 | return errors.New("invalid application id")
108 | }
109 |
110 | source := &core.GithubSource{
111 | UserName: ghUsername,
112 | RepoName: ghReponame,
113 | Release: core.GithubSourceRelease(releaseType),
114 | TagName: tagName,
115 | }
116 | release, err := source.FetchAptRelease()
117 | if err != nil {
118 | return err
119 | }
120 | utils.LogDebug(fmt.Sprintf("selected github tag name: %s", release.TagName))
121 |
122 | matchScore, asset := release.ChooseAptAsset()
123 | if matchScore == core.AppImageAssetNoMatch {
124 | return fmt.Errorf("no valid asset in github tag %s", release.TagName)
125 | }
126 | if matchScore == core.AppImageAssetPartialMatch {
127 | utils.LogWarning("no architecture specified in the asset name, cannot determine compatibility")
128 | }
129 | utils.LogDebug(fmt.Sprintf("selected asset url %s", asset.DownloadUrl))
130 |
131 | appPaths := core.ConstructAppPaths(config, appId, &core.ConstructAppPathsOptions{
132 | Symlink: link,
133 | })
134 | if _, ok := config.Installed[appId]; ok {
135 | utils.LogWarning(fmt.Sprintf("application with id %s already exists", appId))
136 | if !assumeYes {
137 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to re-install this application?")
138 | if err != nil {
139 | return err
140 | }
141 | if !proceed {
142 | utils.LogWarning("aborted...")
143 | return nil
144 | }
145 | }
146 | }
147 |
148 | utils.LogLn()
149 | summary := utils.NewLogTable()
150 | summary.Add(utils.LogRightArrowPrefix, "Identifier", color.CyanString(appId))
151 | summary.Add(utils.LogRightArrowPrefix, "Version", color.CyanString(release.TagName))
152 | summary.Add(utils.LogRightArrowPrefix, "Filename", color.CyanString(asset.Name))
153 | summary.Add(utils.LogRightArrowPrefix, "AppImage", color.CyanString(appPaths.AppImage))
154 | summary.Add(utils.LogRightArrowPrefix, ".desktop file", color.CyanString(appPaths.Desktop))
155 | if appPaths.Symlink != "" {
156 | summary.Add(utils.LogRightArrowPrefix, "Symlink", color.CyanString(appPaths.Symlink))
157 | }
158 | summary.Add(utils.LogRightArrowPrefix, "Download Size", color.CyanString(prettyBytes(asset.Size)))
159 | summary.Print()
160 | utils.LogLn()
161 |
162 | if !assumeYes {
163 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
164 | if err != nil {
165 | return err
166 | }
167 | if !proceed {
168 | utils.LogWarning("aborted...")
169 | return nil
170 | }
171 | }
172 |
173 | app := &core.AppConfig{
174 | Id: appId,
175 | Version: release.TagName,
176 | Source: core.GithubSourceId,
177 | Paths: *appPaths,
178 | }
179 | utils.LogLn()
180 | installed, _ := InstallApps([]InstallableApp{{
181 | App: app,
182 | Source: source,
183 | Asset: asset.ToAsset(),
184 | }})
185 | if installed != 1 {
186 | return nil
187 | }
188 |
189 | utils.LogLn()
190 | utils.LogInfo(
191 | fmt.Sprintf(
192 | "%s Installed %s successfully!",
193 | utils.LogTickPrefix,
194 | color.CyanString(app.Id),
195 | ),
196 | )
197 |
198 | return nil
199 | },
200 | }
201 |
--------------------------------------------------------------------------------
/commands/init.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "os"
8 | "path"
9 |
10 | "github.com/fatih/color"
11 | "github.com/urfave/cli/v3"
12 | "github.com/zyrouge/pho/core"
13 | "github.com/zyrouge/pho/utils"
14 | )
15 |
16 | var InitCommand = cli.Command{
17 | Name: "init",
18 | Usage: "Initialize and setup necessities",
19 | Flags: []cli.Flag{
20 | &cli.StringFlag{
21 | Name: "apps-dir",
22 | Usage: "AppImages directory",
23 | },
24 | &cli.StringFlag{
25 | Name: "apps-desktop-dir",
26 | Usage: ".desktop files directory",
27 | },
28 | &cli.StringFlag{
29 | Name: "apps-link-dir",
30 | Usage: "AppImage symlinks directory",
31 | },
32 | &cli.BoolFlag{
33 | Name: "enable-integration-prompt",
34 | Usage: "Enables AppImageLauncher's integration prompt",
35 | },
36 | &cli.BoolFlag{
37 | Name: "overwrite",
38 | Usage: "Overwrite config if exists",
39 | },
40 | &cli.BoolFlag{
41 | Name: "assume-yes",
42 | Aliases: []string{"y"},
43 | Usage: "Automatically answer 'yes' for questions",
44 | },
45 | },
46 | Action: func(_ context.Context, cmd *cli.Command) error {
47 | appsDir := cmd.String("apps-dir")
48 | appsDesktopDir := cmd.String("apps-desktop-dir")
49 | appsLinkDir := cmd.String("apps-link-dir")
50 | enableIntegrationPromptSet, enableIntegrationPrompt := utils.CommandBoolSetAndValue(cmd, "enable-integration-prompt")
51 | overwrite := cmd.Bool("overwrite")
52 | assumeYes := cmd.Bool("assume-yes")
53 | utils.LogDebug(fmt.Sprintf("argument apps-dir: %s", appsDir))
54 | utils.LogDebug(fmt.Sprintf("argument apps-desktop-dir: %s", appsDesktopDir))
55 | utils.LogDebug(fmt.Sprintf("argument apps-link-dir: %s", appsLinkDir))
56 | utils.LogDebug(fmt.Sprintf("argument enable-integration-prompt: %v", enableIntegrationPrompt))
57 | utils.LogDebug(fmt.Sprintf("argument overwrite: %v", overwrite))
58 | utils.LogDebug(fmt.Sprintf("argument assume-yes: %v", assumeYes))
59 |
60 | reader := bufio.NewReader(os.Stdin)
61 | configPath, err := core.GetConfigPath()
62 | if err != nil {
63 | return err
64 | }
65 | configExists, err := utils.FileExists(configPath)
66 | if err != nil {
67 | return err
68 | }
69 | if configExists {
70 | utils.LogWarning("config already exists")
71 | if !overwrite {
72 | if assumeYes {
73 | return fmt.Errorf(
74 | "pass in %s flag to overwrite configuration file",
75 | color.CyanString("--overwrite"),
76 | )
77 | }
78 | proceed, err := utils.PromptYesNoInput(
79 | reader,
80 | "Do you want to re-initiliaze configuration file?",
81 | )
82 | if err != nil {
83 | return err
84 | }
85 | if !proceed {
86 | return nil
87 | }
88 | }
89 | }
90 |
91 | homeDir, err := os.UserHomeDir()
92 | if err != nil {
93 | return err
94 | }
95 |
96 | if appsDir == "" {
97 | appsDir = path.Join(homeDir, ".local/share", core.AppCodeName, "applications")
98 | if !assumeYes {
99 | appsDir, err = utils.PromptTextInput(
100 | reader,
101 | "Where do you want to store the AppImages?",
102 | appsDir,
103 | )
104 | if err != nil {
105 | return err
106 | }
107 | }
108 | }
109 | appsDir, err = utils.ResolvePath(appsDir)
110 | if err != nil {
111 | return err
112 | }
113 |
114 | if appsDesktopDir == "" {
115 | appsDesktopDir = path.Join(homeDir, ".local/share", "applications")
116 | if !assumeYes {
117 | appsDesktopDir, err = utils.PromptTextInput(
118 | reader,
119 | "Where do you want to store the .desktop files?",
120 | appsDesktopDir,
121 | )
122 | if err != nil {
123 | return err
124 | }
125 | }
126 | }
127 | appsDesktopDir, err = utils.ResolvePath(appsDesktopDir)
128 | if err != nil {
129 | return err
130 | }
131 |
132 | enableAppsLinkDir := true
133 | if !assumeYes {
134 | enableAppsLinkDir, err = utils.PromptYesNoInput(
135 | reader,
136 | "Do you want to symlink AppImage files?",
137 | )
138 | if err != nil {
139 | return err
140 | }
141 | }
142 | if enableAppsLinkDir && appsLinkDir == "" {
143 | appsLinkDir = path.Join(homeDir, ".local/bin")
144 | if !assumeYes {
145 | appsLinkDir, err = utils.PromptTextInput(
146 | reader,
147 | "Where do you want to symlink AppImage files?",
148 | appsLinkDir,
149 | )
150 | if err != nil {
151 | return err
152 | }
153 | }
154 | }
155 | if appsLinkDir != "" {
156 | appsLinkDir, err = utils.ResolvePath(appsLinkDir)
157 | if err != nil {
158 | return err
159 | }
160 | }
161 |
162 | if !enableIntegrationPromptSet && !assumeYes {
163 | enableIntegrationPrompt, err = utils.PromptYesNoInput(
164 | reader,
165 | "Do you want to enable AppImageLauncher's integration prompt?",
166 | )
167 | if err != nil {
168 | return err
169 | }
170 | }
171 |
172 | utils.LogLn()
173 | summary := utils.NewLogTable()
174 | summary.Add(utils.LogRightArrowPrefix, "Configuration file", color.CyanString(configPath))
175 | summary.Add(utils.LogRightArrowPrefix, "AppImages directory", color.CyanString(appsDir))
176 | summary.Add(utils.LogRightArrowPrefix, ".desktop files directory", color.CyanString(appsDesktopDir))
177 | if enableAppsLinkDir {
178 | summary.Add(utils.LogRightArrowPrefix, "AppImages symlink directory", color.CyanString(appsLinkDir))
179 | }
180 | summary.Add(utils.LogRightArrowPrefix, "Enable AppImageLauncher's integration prompt?", color.CyanString(utils.BoolToYesNo(enableIntegrationPrompt)))
181 | summary.Print()
182 | utils.LogLn()
183 |
184 | if !assumeYes {
185 | proceed, err := utils.PromptYesNoInput(reader, "Do you want to proceed?")
186 | if err != nil {
187 | return err
188 | }
189 | if !proceed {
190 | utils.LogWarning("aborted...")
191 | return nil
192 | }
193 | }
194 |
195 | if err := os.MkdirAll(path.Dir(configPath), os.ModePerm); err != nil {
196 | return err
197 | }
198 | if err := os.MkdirAll(path.Dir(appsDir), os.ModePerm); err != nil {
199 | return err
200 | }
201 | if err := os.MkdirAll(path.Dir(appsDesktopDir), os.ModePerm); err != nil {
202 | return err
203 | }
204 | config := &core.Config{
205 | AppsDir: appsDir,
206 | DesktopDir: appsDesktopDir,
207 | Installed: map[string]string{},
208 | EnableIntegrationPrompt: enableIntegrationPrompt,
209 | SymlinksDir: appsLinkDir,
210 | }
211 | err = core.SaveConfig(config)
212 | if err != nil {
213 | return err
214 | }
215 | utils.LogInfo(fmt.Sprintf("%s Generated %s", utils.LogTickPrefix, color.CyanString(configPath)))
216 |
217 | return nil
218 | },
219 | }
220 |
--------------------------------------------------------------------------------
/commands/install.go:
--------------------------------------------------------------------------------
1 | package commands
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "path"
8 | "slices"
9 | "time"
10 |
11 | "github.com/fatih/color"
12 | "github.com/urfave/cli/v3"
13 | "github.com/zyrouge/pho/core"
14 | "github.com/zyrouge/pho/utils"
15 | )
16 |
17 | var InstallCommand = cli.Command{
18 | Name: "install",
19 | Aliases: []string{"add"},
20 | Usage: "Install an application",
21 | Commands: []*cli.Command{
22 | &InstallGithubCommand,
23 | &InstallLocalCommand,
24 | &InstallHttpCommand,
25 | },
26 | }
27 |
28 | type Installable struct {
29 | Name string
30 | Id string
31 | DownloadUrl string
32 | Size int
33 | }
34 |
35 | type InstallableAppStatus int
36 |
37 | const (
38 | InstallableAppFailed InstallableAppStatus = iota
39 | InstallableAppDownloading
40 | InstallableAppIntegrating
41 | InstallableAppInstalled
42 | )
43 |
44 | type InstallableApp struct {
45 | App *core.AppConfig
46 | Source any
47 | Asset *core.Asset
48 |
49 | Index int
50 | Count int
51 | StartedAt int64
52 | Progress int64
53 | RawProgress InstallableAppRawProgress
54 | Speed int64
55 | RemainingSecs int64
56 | PrintCycle int
57 | SkipCycleErase bool
58 | Status InstallableAppStatus
59 | }
60 |
61 | type InstallableAppRawProgress struct {
62 | Sizes []int
63 | Times []int64
64 | }
65 |
66 | func (x *InstallableApp) Write(data []byte) (n int, err error) {
67 | l := len(data)
68 | now := utils.TimeNowSeconds()
69 | x.Progress += int64(l)
70 | x.RawProgress.Sizes = append(x.RawProgress.Sizes, l)
71 | x.RawProgress.Times = append(x.RawProgress.Times, now)
72 | remove := 0
73 | for i, t := range x.RawProgress.Times {
74 | if now-t < 10 {
75 | break
76 | }
77 | remove = i
78 | }
79 | if remove > 0 {
80 | x.RawProgress.Sizes = slices.Delete(x.RawProgress.Sizes, 0, remove)
81 | x.RawProgress.Times = slices.Delete(x.RawProgress.Times, 0, remove)
82 | }
83 | return l, nil
84 | }
85 |
86 | func (x *InstallableApp) logDebug(msg string) {
87 | if utils.LogDebugEnabled {
88 | x.SkipCycleErase = true
89 | utils.LogDebug(msg)
90 | }
91 | }
92 |
93 | func (x *InstallableApp) PrintStatus() {
94 | if x.PrintCycle > 0 && !x.SkipCycleErase {
95 | utils.TerminalErasePreviousLine()
96 | x.SkipCycleErase = false
97 | }
98 | x.PrintCycle++
99 |
100 | prefix := color.HiBlackString(fmt.Sprintf("[%d/%d]", x.Index+1, x.Count))
101 | elapsedSecs := utils.HumanizeSeconds(utils.TimeNowSeconds() - x.StartedAt)
102 | suffix := color.HiBlackString(fmt.Sprintf("(%s)", elapsedSecs))
103 |
104 | switch x.Status {
105 | case InstallableAppFailed:
106 | fmt.Printf(
107 | "%s %s %s %s %s\n",
108 | prefix,
109 | utils.LogExclamationPrefix,
110 | color.RedString(x.App.Id),
111 | x.App.Version,
112 | suffix,
113 | )
114 |
115 | case InstallableAppDownloading:
116 | x.calculateMetrics()
117 | suffix := color.HiBlackString(
118 | fmt.Sprintf(
119 | "(%s / %s @ %s/s)",
120 | elapsedSecs,
121 | utils.HumanizeSeconds(x.RemainingSecs),
122 | prettyBytes(x.Speed),
123 | ),
124 | )
125 | fmt.Printf(
126 | "%s %s %s %s (%s / %s) %s\n",
127 | prefix,
128 | color.YellowString(utils.TerminalLoadingSymbol(x.PrintCycle)),
129 | color.CyanString(x.App.Id),
130 | x.App.Version,
131 | prettyBytes(x.Progress),
132 | prettyBytes(x.Asset.Size),
133 | suffix,
134 | )
135 |
136 | case InstallableAppIntegrating:
137 | fmt.Printf(
138 | "%s %s %s %s %s\n",
139 | prefix,
140 | color.YellowString(utils.TerminalLoadingSymbol(x.PrintCycle)),
141 | color.CyanString(x.App.Id),
142 | x.App.Version,
143 | suffix,
144 | )
145 |
146 | case InstallableAppInstalled:
147 | fmt.Printf(
148 | "%s %s %s %s %s\n",
149 | prefix,
150 | utils.LogTickPrefix,
151 | color.GreenString(x.App.Id),
152 | x.App.Version,
153 | suffix,
154 | )
155 | }
156 | }
157 |
158 | const printStatusTickerDuration = time.Second / 4
159 |
160 | func (x *InstallableApp) StartStatusTicker() *time.Ticker {
161 | ticker := time.NewTicker(printStatusTickerDuration)
162 | go func() {
163 | for range ticker.C {
164 | x.PrintStatus()
165 | }
166 | }()
167 | return ticker
168 | }
169 |
170 | func InstallApps(apps []InstallableApp) (int, int) {
171 | success := 0
172 | count := len(apps)
173 | for i := range apps {
174 | x := &apps[i]
175 | x.Index = i
176 | x.Count = count
177 | x.StartedAt = utils.TimeNowSeconds()
178 | x.Status = InstallableAppDownloading
179 | x.RawProgress = InstallableAppRawProgress{
180 | Sizes: []int{},
181 | Times: []int64{},
182 | }
183 | x.PrintStatus()
184 | x.logDebug("updating transactions")
185 | core.UpdateTransactions(func(transactions *core.Transactions) error {
186 | transactions.PendingInstallations[x.App.Id] = core.PendingInstallation{
187 | InvolvedDirs: []string{x.App.Paths.Dir},
188 | InvolvedFiles: []string{x.App.Paths.Desktop},
189 | }
190 | return nil
191 | })
192 | if err := x.Install(); err != nil {
193 | x.Status = InstallableAppFailed
194 | x.PrintStatus()
195 | utils.LogError(err)
196 | break
197 | } else {
198 | x.Status = InstallableAppInstalled
199 | x.PrintStatus()
200 | success++
201 | }
202 | x.logDebug("updating transactions")
203 | core.UpdateTransactions(func(transactions *core.Transactions) error {
204 | delete(transactions.PendingInstallations, x.App.Id)
205 | return nil
206 | })
207 | }
208 | return success, count - success
209 | }
210 |
211 | func (x *InstallableApp) Install() error {
212 | ticker := x.StartStatusTicker()
213 | defer ticker.Stop()
214 | if err := x.Download(); err != nil {
215 | return err
216 | }
217 | x.Status = InstallableAppIntegrating
218 | if err := x.Integrate(); err != nil {
219 | return err
220 | }
221 | if err := x.SaveConfig(); err != nil {
222 | return err
223 | }
224 | return nil
225 | }
226 |
227 | func (x *InstallableApp) Download() error {
228 | x.logDebug(fmt.Sprintf("creating %s", x.App.Paths.Dir))
229 | if err := os.MkdirAll(x.App.Paths.Dir, os.ModePerm); err != nil {
230 | return err
231 | }
232 | x.logDebug(fmt.Sprintf("creating %s", x.App.Paths.Desktop))
233 | if err := os.MkdirAll(path.Dir(x.App.Paths.Desktop), os.ModePerm); err != nil {
234 | return err
235 | }
236 | tempFile, err := utils.CreateTempFile(x.App.Paths.AppImage)
237 | if err != nil {
238 | return err
239 | }
240 | x.logDebug(fmt.Sprintf("created %s", tempFile.Name()))
241 | defer tempFile.Close()
242 | data, err := x.Asset.Download()
243 | if err != nil {
244 | return err
245 | }
246 | defer data.Close()
247 | mw := io.MultiWriter(tempFile, x)
248 | _, err = io.Copy(mw, data)
249 | if err != nil {
250 | return err
251 | }
252 | x.logDebug(fmt.Sprintf("renaming %s to %s", tempFile.Name(), x.App.Paths.AppImage))
253 | if err = os.Rename(tempFile.Name(), x.App.Paths.AppImage); err != nil {
254 | return err
255 | }
256 | x.logDebug(fmt.Sprintf("changing permissions of %s", x.App.Paths.AppImage))
257 | return os.Chmod(x.App.Paths.AppImage, 0755)
258 | }
259 |
260 | func (x *InstallableApp) Integrate() error {
261 | tempDir := path.Join(x.App.Paths.Dir, "temp")
262 | x.logDebug(fmt.Sprintf("creating %s", tempDir))
263 | err := os.Mkdir(tempDir, os.ModePerm)
264 | if err != nil {
265 | return err
266 | }
267 | x.logDebug(fmt.Sprintf("deflating %s into %s", x.App.Paths.AppImage, tempDir))
268 | deflated, err := core.DeflateAppImage(x.App.Paths.AppImage, tempDir)
269 | if err != nil {
270 | return err
271 | }
272 | defer os.RemoveAll(tempDir)
273 | metadata, err := deflated.ExtractMetadata()
274 | if err != nil {
275 | return err
276 | }
277 | x.logDebug(fmt.Sprintf("creating %s", x.App.Paths.Icon))
278 | if err = metadata.CopyIconFile(&x.App.Paths); err != nil {
279 | return err
280 | }
281 | x.logDebug(fmt.Sprintf("installing .desktop file at %s", x.App.Paths.Desktop))
282 | if err = metadata.InstallDesktopFile(&x.App.Paths); err != nil {
283 | return err
284 | }
285 | if x.App.Paths.Symlink != "" {
286 | x.logDebug(fmt.Sprintf("creating symlink %s", x.App.Paths.Symlink))
287 | if err = metadata.Symlink(&x.App.Paths); err != nil {
288 | return err
289 | }
290 | }
291 | return nil
292 | }
293 |
294 | func (x *InstallableApp) SaveConfig() error {
295 | x.logDebug(fmt.Sprintf("saving app config to %s", x.App.Paths.Config))
296 | if err := core.SaveAppConfig(x.App.Paths.Config, x.App); err != nil {
297 | return err
298 | }
299 | x.logDebug(fmt.Sprintf("saving app source config to %s", x.App.Paths.SourceConfig))
300 | if err := core.SaveSourceConfig[any](x.App.Paths.SourceConfig, x.Source); err != nil {
301 | return err
302 | }
303 | config, err := core.ReadConfig()
304 | if err != nil {
305 | return err
306 | }
307 | config.Installed[x.App.Id] = x.App.Paths.Config
308 | x.logDebug("saving config")
309 | return core.SaveConfig(config)
310 | }
311 |
312 | func prettyBytes(size int64) string {
313 | if size < 1000 {
314 | kb := float32(size) / 1000
315 | return fmt.Sprintf("%.2f KB", kb)
316 | }
317 | mb := float32(size) / 1000000
318 | return fmt.Sprintf("%.2f MB", mb)
319 | }
320 |
321 | func (x *InstallableApp) calculateMetrics() {
322 | count := len(x.RawProgress.Sizes)
323 | if count < 2 {
324 | return
325 | }
326 | total := int64(0)
327 | for _, x := range x.RawProgress.Sizes {
328 | total += int64(x)
329 | }
330 | time := max(1, x.RawProgress.Times[count-1]-x.RawProgress.Times[0])
331 | x.Speed = total / time
332 | if x.Speed > 0 {
333 | x.RemainingSecs = (x.Asset.Size - x.Progress) / x.Speed
334 | } else {
335 | x.RemainingSecs = 0
336 | }
337 | }
338 |
--------------------------------------------------------------------------------