diff --git a/README.md b/README.md index 7786977..6eb3be3 100644 --- a/README.md +++ b/README.md @@ -4,67 +4,77 @@ [F-Droid](https://f-droid.org/) desktop client. Requires Go 1.19 or later. - go install mvdan.cc/fdroidcl@latest +```sh + go install mvdan.cc/fdroidcl@latest +``` While the Android client integrates with the system with regular update checks and notifications, this is a simple command line client that talks to connected devices via [ADB](https://developer.android.com/tools/help/adb.html). -### Quickstart +## Quickstart Download the index: - fdroidcl update +```sh + fdroidcl update +``` Show all available apps: - fdroidcl search +```sh + fdroidcl search +``` Install an app: - fdroidcl install org.adaway +```sh + fdroidcl install org.adaway +``` Show all available updates, and install them: - fdroidcl search -u - fdroidcl install -u - -Unofficial packages are available on: [Debian](https://packages.debian.org/buster/fdroidcl) and [Ubuntu](https://packages.ubuntu.com/eoan/fdroidcl). - -### Commands - - update Update the index - search [] Search available apps - show Show detailed info about apps - install [] Install or upgrade apps - uninstall Uninstall an app - download Download an app - devices List connected devices - list (categories/users) List all known values of a kind - repo Manage repositories - clean Clean index and/or cache - defaults Reset to the default settings - version Print version information +```sh + fdroidcl search -u + fdroidcl install -u +``` +Unofficial packages are available on: +[Debian](https://packages.debian.org/buster/fdroidcl) and +[Ubuntu](https://packages.ubuntu.com/eoan/fdroidcl). + +## Commands + +```text + update Update the index + search [] Search available apps + show Show detailed info about apps + install [] Install or upgrade apps + uninstall Uninstall an app + download Download an app + devices List connected devices + scan Scan for known fdroid apps on a device + list (categories/users) List all known values of a kind + repo Manage repositories + setups Manage setups + clean Clean index and/or cache + defaults Reset to the default settings + version Print version information +``` An appid is just an app's unique package name. A specific version of an app can be selected by following the appid with a colon and the version code. The 'search' and 'show' commands can be used to find these strings. For example: - $ fdroidcl search redreader - $ fdroidcl show org.quantumbadger.redreader - $ fdroidcl install org.quantumbadger.redreader:85 - -### Config - -You can configure what repositories to use in the `config.json` file. On Linux, -you will likely find it at `~/.config/fdroidcl/config.json`. - -You can run `fdroidcl defaults` to create the config with the default settings. +```sh + fdroidcl search redreader + fdroidcl show org.quantumbadger.redreader + fdroidcl install org.quantumbadger.redreader:85 +``` -#### *new: you can manage the repositories now directly via cli* +### *new: you can manage the repositories now directly via cli* -``` +```text usage: fdroidcl repo List, add, remove, enable or disable repositories. @@ -82,25 +92,60 @@ Modify repositories: $ fdroidcl repo disable ``` -### Advantages over the Android client +### *new: you can manage automating installs using setups* + +```text +usage: fdroidcl setup + +List, add, remove, edit, and import/export setups. +Setups allow for mass installs onto an android device, excellent for backups. + +List setups: + + $ fdroidcl setup Show all setups + $ fdroidcl setup list Show details about one setup + +Modify setups: + $ fdroidcl setup new + $ fdroidcl setup remove + $ fdroidcl setup apply + $ fdroidcl setup add-app + $ fdroidcl setup rm-app + $ fdroidcl setup add-repo + $ fdroidcl setup rm-repo + +Export setups: + + $ fdroidcl setup import + $ fdroidcl setup export +``` + +## Config + +You can configure what repositories to use in the `config.toml` file. On Linux, +you will likely find it at `~/.config/fdroidcl/config.toml`. + +You can run `fdroidcl defaults` to create the config with the default settings. + +## Advantages over the Android client * Command line interface * Batch install/update/remove apps without root nor system privileges * No need to install a client on the device -### What it will never do +## What it will never do * Run as a daemon, e.g. periodic index updates * Act as an F-Droid server * Swap apps with devices -### Caveats +## Caveats * Index verification relies on HTTPS (not the JAR signature) * The tool can only interact with one device at a time * Hardware compatibility of packages is not checked -### FAQ +## FAQ * What's the point of a desktop client? diff --git a/contrib/completion/zsh/_fdroidcl b/contrib/completion/zsh/_fdroidcl index e47b9db..3b4ce66 100644 --- a/contrib/completion/zsh/_fdroidcl +++ b/contrib/completion/zsh/_fdroidcl @@ -8,9 +8,12 @@ _fdroidcl() { 'show:show detailed info about an app' 'list:list all known values of a kind' 'devices:list connected devices' + 'scan:list all fdroid apps on a device' 'download:download an app' 'install:install or upgrade an app' 'uninstall:uninstall an app' + 'setup:manage setups' + 'repo:manage repos' 'defaults:reset to the default settings') _arguments \ diff --git a/defaults.go b/defaults.go index 42d8198..f6d650a 100644 --- a/defaults.go +++ b/defaults.go @@ -4,9 +4,10 @@ package main import ( - "encoding/json" "fmt" "os" + + "github.com/pelletier/go-toml/v2" ) var cmdDefaults = &Command{ @@ -26,7 +27,7 @@ func runDefaults(args []string) error { } func writeConfig(c *userConfig) error { - b, err := json.MarshalIndent(c, "", "\t") + b, err := toml.Marshal(c) if err != nil { return fmt.Errorf("cannot encode config: %v", err) } diff --git a/go.mod b/go.mod index f68270e..38507a5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module mvdan.cc/fdroidcl -go 1.19 +go 1.21.0 + +toolchain go1.23.0 require ( github.com/kr/pretty v0.3.1 @@ -12,6 +14,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/rivo/uniseg v0.4.4 // indirect golang.org/x/sys v0.12.0 // indirect golang.org/x/term v0.12.0 // indirect diff --git a/go.sum b/go.sum index 362e9f9..f6cf6a7 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/main.go b/main.go index 582ec9c..f16972f 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,13 @@ package main import ( - "encoding/json" "flag" "fmt" "os" "path/filepath" "strings" + + "github.com/pelletier/go-toml/v2" ) const cmdName = "fdroidcl" @@ -43,17 +44,24 @@ func mustData() string { } func configPath() string { - return filepath.Join(mustData(), "config.json") + return filepath.Join(mustData(), "config.toml") } type repo struct { - ID string `json:"id"` - URL string `json:"url"` - Enabled bool `json:"enabled"` + ID string `toml:"id"` + URL string `toml:"url"` + Enabled bool `toml:"enabled"` +} + +type setup struct { + ID string `toml:"id"` + Apps []string `toml:"apps"` + Repos []string `toml:"repos"` } type userConfig struct { - Repos []repo `json:"repos"` + Repos []repo `toml:"repos"` + Setups []setup `toml:"setups"` } var config = userConfig{ @@ -69,6 +77,7 @@ var config = userConfig{ Enabled: false, }, }, + Setups: []setup{}, } func readConfig() error { @@ -79,7 +88,7 @@ func readConfig() error { } defer f.Close() fileConfig := userConfig{} - err = json.NewDecoder(f).Decode(&fileConfig) + err = toml.NewDecoder(f).Decode(&fileConfig) if err != nil { return err } @@ -168,8 +177,10 @@ var commands = []*Command{ cmdUninstall, cmdDownload, cmdDevices, + cmdScan, cmdList, cmdRepo, + cmdSetup, cmdClean, cmdDefaults, cmdVersion, diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..9a5d410 --- /dev/null +++ b/scan.go @@ -0,0 +1,67 @@ +// Copyright (c) 2024, Thomas Dickson +// See LICENSE for licensing information + +package main + +import ( + "fmt" + "os" + "strings" + + "mvdan.cc/fdroidcl/adb" +) + +var cmdScan = &Command{ + UsageLine: "scan", + Short: "Scan for all recognised apps on a connected device", +} + +func init() { + cmdScan.Run = runScan +} + +func runScan(args []string) error { + if err := startAdbIfNeeded(); err != nil { + return err + } + devices, err := adb.Devices() + if err != nil { + return fmt.Errorf("could not get devices: %v", err) + } + + if len(devices) == 0 { + return fmt.Errorf("no devices found") + } + + for _, device := range devices { + fmt.Fprintf(os.Stderr, "Scanning %s - %s (%s)\n", device.ID, device.Model, device.Product) + scanForPackages(device) + fmt.Fprintln(os.Stderr, "Scan completed without error") + } + return nil +} + +func scanForPackages(device *adb.Device) { + cmd := device.AdbShell("pm list packages") + + out, err := cmd.Output() + if err != nil { + fmt.Println("could not run command: ", err) + } + // fmt.Println(string(out)) + // otherwise, print the output from running the command + lines := strings.Split(string(out), "\n") + // fmt.Println(lines) + + for _, line := range lines { + if len(line) > 8 { + line = line[8:] + // fmt.Println(line) + + apps, err := findApps([]string{line}) + if err == nil { + fmt.Println(apps[0].PackageName) + } + } + } +} diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..efdca97 --- /dev/null +++ b/setup.go @@ -0,0 +1,292 @@ +// Copyright (c) 2024, Thomas Dickson +// See LICENSE for licensing information + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +var cmdSetup = &Command{ + UsageLine: "setup", + Short: "Manage setups", + Long: ` +List, add, remove, edit, and import/export setups. +Setups allow for mass installs onto an android device, excellent for backups. + +List setups: + + $ fdroidcl setup Show all setups + $ fdroidcl setup list Show details about one setup + +Modify setups: + + $ fdroidcl setup new + $ fdroidcl setup remove + $ fdroidcl setup apply + $ fdroidcl setup add-app + $ fdroidcl setup rm-app + $ fdroidcl setup add-repo + $ fdroidcl setup rm-repo + +Export setups: + + $ fdroidcl setup import + $ fdroidcl setup export +`[1:], +} + +func init() { + cmdSetup.Run = runSetup +} + +func runSetup(args []string) error { + if len(args) == 0 { + // list repositories + if len(config.Setups) == 0 { + fmt.Println("No setups!") + } else { + for _, value := range config.Setups { + fmt.Printf("Name: %s\n", value.ID) + } + } + return nil + } + + switch args[0] { + case "list": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return listSetup(args[1]) + case "new": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return newSetup(args[1]) + case "apply": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return applySetup(args[1]) + case "remove": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return removeSetup(args[1]) + case "add-app": + if len(args) != 3 { + return fmt.Errorf("wrong amount of arguments") + } + return addSetupApp(args[1], args[2]) + case "rm-app": + if len(args) != 3 { + return fmt.Errorf("wrong amount of arguments") + } + return removeSetupApp(args[1], args[2]) + case "add-repo": + if len(args) != 3 { + return fmt.Errorf("wrong amount of arguments") + } + return removeSetupRepo(args[1], args[2]) + case "rm-repo": + if len(args) != 3 { + return fmt.Errorf("wrong amount of arguments") + } + return removeSetupRepo(args[1], args[2]) + case "import": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return importSetup(args[1]) + case "export": + if len(args) != 2 { + return fmt.Errorf("wrong amount of arguments") + } + return exportSetup(args[1]) + + } + return fmt.Errorf("wrong usage") +} + +func getIndex(sl []string, str string) int { + index := -1 + for i, val := range sl { + if val == str { + index = i + break + } + } + return index +} + +func listSetup(name string) error { + index := setupIndex(name) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", name) + } + + setup := config.Setups[index] + fmt.Printf("Name: %s\n", setup.ID) + fmt.Printf("Repos: %s\n", setup.Repos) + fmt.Printf("Apps: %s\n", setup.Apps) + + return nil +} + +func setupIndex(name string) int { + index := -1 + for i, value := range config.Setups { + if value.ID == name { + index = i + break + } + } + return index +} + +func newSetup(name string) error { + repos := []string{} + for _, repo := range config.Repos { + repos = append(repos, repo.ID) + } + config.Setups = append(config.Setups, setup{name, []string{}, repos}) + return writeConfig(&config) +} + +func applySetup(name string) error { + index := setupIndex(name) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", name) + } + + setup := config.Setups[index] + for _, repo := range setup.Repos { + index = repoIndex(repo) + if index == -1 { + return fmt.Errorf("setup contains unknown repo id \"%s\" ", repo) + } + } + fmt.Println("All repos work!") + + if len(setup.Apps) == 0 { + return fmt.Errorf("setup has no apps!") + } + + for _, app := range setup.Apps { + err := runInstall([]string{app}) + if err != nil { + fmt.Println(err) + } + } + return nil +} + +func removeSetup(name string) error { + index := setupIndex(name) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", name) + } + config.Setups = append(config.Setups[:index], config.Setups[index+1:]...) + return writeConfig(&config) +} + +func addSetupApp(setupName string, appName string) error { + index := setupIndex(setupName) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", setupName) + } + config.Setups[index].Apps = append(config.Setups[index].Apps, appName) + return writeConfig(&config) +} + +func removeSetupApp(setupName string, appName string) error { + index := setupIndex(setupName) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", setupName) + } + + apps := config.Setups[index].Apps + appIndex := getIndex(apps, appName) + if appIndex == -1 { + return fmt.Errorf("a app with the name \"%s\" could not be found for the setup with the name \"%s\"", appName, setupName) + } + config.Setups[index].Apps = append(apps[:appIndex], apps[appIndex+1:]...) + + return writeConfig(&config) +} + +func addSetupRepo(setupName string, repoName string) error { + repoIndex := repoIndex(repoName) + if repoIndex == -1 { + return fmt.Errorf("a repo with the name \"%s\" could not be found", repoName) + } + + index := setupIndex(setupName) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", setupName) + } + config.Setups[index].Repos = append(config.Setups[index].Repos, repoName) + return writeConfig(&config) +} + +func removeSetupRepo(setupName string, repoName string) error { + index := setupIndex(setupName) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", setupName) + } + + repos := config.Setups[index].Repos + repoIndex := getIndex(repos, repoName) + if repoIndex == -1 { + return fmt.Errorf("a repo with the name \"%s\" could not be found for the setup with the name \"%s\"", repoName, setupName) + } + config.Setups[index].Repos = append(repos[:repoIndex], repos[repoIndex+1:]...) + + return writeConfig(&config) +} + +func importSetup(filename string) error { + filename, err := filepath.Abs(filename) + if err != nil { + return err + } + + f, err := os.Open(filename) + if err != nil { + return fmt.Errorf("file \"%s\" does not exist", filename) + } + defer f.Close() + fileSetup := setup{} + err = toml.NewDecoder(f).Decode(&fileSetup) + if err != nil { + return err + } + + config.Setups = append(config.Setups, fileSetup) + return writeConfig(&config) +} + +func exportSetup(name string) error { + index := setupIndex(name) + if index == -1 { + return fmt.Errorf("a setup with the name \"%s\" could not be found", name) + } + b, err := toml.Marshal(config.Setups[index]) + if err != nil { + return fmt.Errorf("cannot encode config: %v", err) + } + f, err := os.Create(config.Setups[index].ID + ".toml") + if err != nil { + return fmt.Errorf("cannot create config file: %v", err) + } + _, err = f.Write(b) + if cerr := f.Close(); err == nil { + err = cerr + } + return err +}