├── .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 | [![Latest Version](https://img.shields.io/github/v/release/zyrouge/pho?label=latest)](https://github.com/zyrouge/pho/releases/latest) 4 | [![Build](https://github.com/zyrouge/pho/actions/workflows/build.yml/badge.svg)](https://github.com/zyrouge/pho/actions/workflows/build.yml) 5 | [![Release](https://github.com/zyrouge/pho/actions/workflows/release.yml/badge.svg)](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 | --------------------------------------------------------------------------------