Commit 39fee3fe authored by Jochen Sprickerhof's avatar Jochen Sprickerhof

Update upstream source from tag 'upstream/0.4.0'

Update to upstream version '0.4.0'
with Debian dir 17e4275fb37b543f9379c39c7e019b3f81223e30
parents 3d350fb3 4dbb781a
.pc
# Binaries
cmd/fdroidcl/fdroidcl
# Indexes
*.xml
*.jar
*-etag
language: go
go:
- 1.7.x
- 1.8.x
- 1.9.x
- 1.10.x
go_import_path: mvdan.cc/fdroidcl
# fdroidcl
[![GoDoc](https://godoc.org/github.com/mvdan/fdroidcl?status.svg)](https://godoc.org/github.com/mvdan/fdroidcl)
[![GoDoc](https://godoc.org/github.com/mvdan/fdroidcl?status.svg)](https://godoc.org/mvdan.cc/fdroidcl)
[![Build Status](https://travis-ci.org/mvdan/fdroidcl.svg?branch=master)](https://travis-ci.org/mvdan/fdroidcl)
[F-Droid](https://f-droid.org/) desktop client.
go get -u github.com/mvdan/fdroidcl/cmd/fdroidcl
This is **not** a replacement for the [Android client](https://gitlab.com/fdroid/fdroidclient).
go get -u mvdan.cc/fdroidcl/cmd/fdroidcl
While the Android client integrates with the system with regular update checks
and notifications, this is a command line client that talks to connected
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
......@@ -34,38 +32,34 @@ Install an app:
show <appid...> Show detailed info about an app
devices List connected devices
download <appid...> Download an app
install <appid...> Install an app
upgrade <appid...> Upgrade an app
install <appid...> Install or upgrade app
uninstall <appid...> Uninstall an app
defaults Reset to the default settings
A specific version of an app can be selected by following the appid with an colon (:) and the version code of the app to select.
A specific version of an app can be selected by following the appid with an
colon (:) and the version code of the app to select.
### Config
You can configure the repositories to use in the `config.json` file,
located in `fdroidcl`'s config directory. This will be
`~/.config/fdroidcl/config.json` on Linux.
You can run `fdroidcl defaults` to create the config with the default
settings.
### Missing features
You can configure what repositories to use in the `config.json` file. On Linux,
you will likely find it at `~/.config/fdroidcl/config.json`.
* Index verification via jar signature - currently relies on HTTPS
* Interaction with multiple devices at once
* Hardware features filtering
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
* Handle multiple Android devices
* No need to install a client on the device
* 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
* Run as a daemon, e.g. periodic index updates
* Graphical user interface
* Act as an F-Droid server
* Swap apps with devices running the Android client
* Run as a daemon, e.g. periodic index updates
* Act as an F-Droid server
* Swap apps with devices
### 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
......@@ -136,13 +136,8 @@ func getAbis(props map[string]string) []string {
var installFailureRegex = regexp.MustCompile(`^Failure \[INSTALL_(.+)\]$`)
func withOpts(cmd string, opts []string, args ...string) []string {
v := append([]string{cmd}, opts...)
return append(v, args...)
}
func (d *Device) install(opts []string, path string) error {
cmd := d.AdbCmd(withOpts("install", opts, path)...)
func (d *Device) Install(path string) error {
cmd := d.AdbCmd(append([]string{"install", "-r"}, path)...)
output, err := cmd.CombinedOutput()
if err != nil {
return err
......@@ -154,14 +149,6 @@ func (d *Device) install(opts []string, path string) error {
return parseError(getFailureCode(installFailureRegex, line))
}
func (d *Device) Install(path string) error {
return d.install(nil, path)
}
func (d *Device) Upgrade(path string) error {
return d.install([]string{"-r"}, path)
}
func getResultLine(output []byte) string {
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
......@@ -193,9 +180,9 @@ func (d *Device) Uninstall(pkg string) error {
}
type Package struct {
ID string
VCode int
VName string
ID string
VersCode int
VersName string
}
var (
......@@ -232,9 +219,9 @@ func (d *Device) Installed() (map[string]Package, error) {
if err != nil {
panic(err)
}
cur.VCode = n
cur.VersCode = n
} else if m := verNameRegex.FindStringSubmatch(l); m != nil {
cur.VName = m[1]
cur.VersName = m[1]
}
}
if !first {
......
// Copyright (c) 2015, Daniel Martí <mvdan@mvdan.cc>
// See LICENSE for licensing information
package basedir
import (
"os"
"os/user"
"path/filepath"
)
// Cache returns the base cache directory.
func Cache() string {
return cache()
}
// Data returns the base data directory.
func Data() string {
return data()
}
func firstGetenv(def string, evs ...string) string {
for _, ev := range evs {
if v := os.Getenv(ev); v != "" {
return v
}
}
home, err := homeDir()
if err != nil {
return ""
}
return filepath.Join(home, def)
}
func homeDir() (string, error) {
curUser, err := user.Current()
if err != nil {
return "", err
}
return curUser.HomeDir, nil
}
// Copyright (c) 2015, Daniel Martí <mvdan@mvdan.cc>
// See LICENSE for licensing information
package basedir
var (
cacheDir = firstGetenv("Library/Caches")
dataDir = firstGetenv("Library/Application Support")
)
func cache() string {
return cacheDir
}
func data() string {
return dataDir
}
// Copyright (c) 2015, Daniel Martí <mvdan@mvdan.cc>
// See LICENSE for licensing information
// +build dragonfly freebsd linux netbsd openbsd
package basedir
var (
cacheDir = firstGetenv(".cache", "XDG_CACHE_HOME")
dataDir = firstGetenv(".config", "XDG_CONFIG_HOME")
)
func cache() string {
return cacheDir
}
func data() string {
return dataDir
}
// Copyright (c) 2015, Daniel Martí <mvdan@mvdan.cc>
// See LICENSE for licensing information
package basedir
var (
cacheDir = firstGetenv("", "TEMP", "TMP")
dataDir = firstGetenv("", "APPDATA")
)
func cache() string {
return cacheDir
}
func data() string {
return dataDir
}
......@@ -6,7 +6,7 @@ package main
import (
"fmt"
"github.com/mvdan/fdroidcl/adb"
"mvdan.cc/fdroidcl/adb"
)
var cmdDevices = &Command{
......@@ -27,7 +27,7 @@ func runDevices(args []string) error {
return fmt.Errorf("could not get devices: %v", err)
}
for _, device := range devices {
fmt.Printf("%s - %s (%s)\n", device.ID, device.Model, device.Product)
fmt.Fprintf(stdout, "%s - %s (%s)\n", device.ID, device.Model, device.Product)
}
return nil
}
......
......@@ -7,7 +7,7 @@ import (
"fmt"
"path/filepath"
"github.com/mvdan/fdroidcl"
"mvdan.cc/fdroidcl"
)
var cmdDownload = &Command{
......@@ -34,13 +34,13 @@ func runDownload(args []string) error {
for _, app := range apps {
apk := app.SuggestedApk(device)
if apk == nil {
return fmt.Errorf("no suggested APK found for %s", app.ID)
return fmt.Errorf("no suggested APK found for %s", app.PackageName)
}
path, err := downloadApk(apk)
if err != nil {
return err
}
fmt.Printf("APK available in %s\n", path)
fmt.Fprintf(stdout, "APK available in %s\n", path)
}
return nil
}
......@@ -48,7 +48,7 @@ func runDownload(args []string) error {
func downloadApk(apk *fdroidcl.Apk) (string, error) {
url := apk.URL()
path := apkPath(apk.ApkName)
if err := downloadEtag(url, path, apk.Hash.Data); err == errNotModified {
if err := downloadEtag(url, path, apk.Hash); err == errNotModified {
} else if err != nil {
return "", fmt.Errorf("could not download %s: %v", apk.AppID, err)
}
......
// Copyright (c) 2018, Daniel Martí <mvdan@mvdan.cc>
// See LICENSE for licensing information
package main
import (
"bytes"
"io/ioutil"
"net/http"
"os"
"regexp"
"testing"
"time"
"mvdan.cc/fdroidcl/adb"
)
// chosenApp is the app that will be installed and uninstalled on a connected
// device. This one was chosen because it's tiny, requires no permissions, and
// should be compatible with every device.
//
// It also stores no data, so it is fine to uninstall it and the user won't lose
// any data.
const chosenApp = "org.vi_server.red_screen"
func TestCommands(t *testing.T) {
url := config.Repos[0].URL
client := http.Client{Timeout: 2 * time.Second}
if _, err := client.Get(url); err != nil {
t.Skipf("skipping since %s is unreachable: %v", url, err)
}
dir, err := ioutil.TempDir("", "fdroidcl")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
testBasedir = dir
mustSucceed := func(t *testing.T, wantRe, negRe string, cmd *Command, args ...string) {
mustRun(t, true, wantRe, negRe, cmd, args...)
}
mustFail := func(t *testing.T, wantRe, negRe string, cmd *Command, args ...string) {
mustRun(t, false, wantRe, negRe, cmd, args...)
}
t.Run("Version", func(t *testing.T) {
mustSucceed(t, `^v`, ``, cmdVersion)
})
t.Run("SearchBeforeUpdate", func(t *testing.T) {
mustFail(t, `could not open index`, ``, cmdSearch)
})
t.Run("UpdateFirst", func(t *testing.T) {
mustSucceed(t, `done`, ``, cmdUpdate)
})
t.Run("UpdateCached", func(t *testing.T) {
mustSucceed(t, `not modified`, ``, cmdUpdate)
})
t.Run("SearchNoArgs", func(t *testing.T) {
mustSucceed(t, `F-Droid`, ``, cmdSearch)
})
t.Run("SearchWithArgs", func(t *testing.T) {
mustSucceed(t, `F-Droid`, ``, cmdSearch, "fdroid.fdroid")
})
t.Run("SearchWithArgsNone", func(t *testing.T) {
mustSucceed(t, `^$`, ``, cmdSearch, "nomatches")
})
t.Run("SearchOnlyPackageNames", func(t *testing.T) {
mustSucceed(t, `^[^ ]*$`, ``, cmdSearch, "-q", "fdroid.fdroid")
})
t.Run("ShowOne", func(t *testing.T) {
mustSucceed(t, `fdroid/fdroidclient`, ``, cmdShow, "org.fdroid.fdroid")
})
t.Run("ShowMany", func(t *testing.T) {
mustSucceed(t, `fdroid/fdroidclient.*fdroid/privileged-extension`, ``,
cmdShow, "org.fdroid.fdroid", "org.fdroid.fdroid.privileged")
})
t.Run("ListCategories", func(t *testing.T) {
mustSucceed(t, `Development`, ``, cmdList, "categories")
})
if err := startAdbIfNeeded(); err != nil {
t.Log("skipping the device tests as ADB is not installed")
return
}
devices, err := adb.Devices()
if err != nil {
t.Fatal(err)
}
switch len(devices) {
case 0:
t.Log("skipping the device tests as none was found via ADB")
return
case 1:
// continue below
default:
t.Log("skipping the device tests as too many were found via ADB")
return
}
t.Run("DevicesOne", func(t *testing.T) {
mustSucceed(t, `\n`, ``, cmdDevices)
})
// try to uninstall the app first
devices[0].Uninstall(chosenApp)
t.Run("UninstallMissing", func(t *testing.T) {
mustFail(t, `not installed$`, ``, cmdUninstall, chosenApp)
})
t.Run("SearchInstalledMissing", func(t *testing.T) {
mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-i", "-q")
})
t.Run("SearchUpgradableMissing", func(t *testing.T) {
mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-u", "-q")
})
t.Run("InstallVersioned", func(t *testing.T) {
mustSucceed(t, `Installing `+regexp.QuoteMeta(chosenApp), ``,
cmdInstall, chosenApp+":1")
})
t.Run("SearchInstalled", func(t *testing.T) {
time.Sleep(3 * time.Second)
mustSucceed(t, regexp.QuoteMeta(chosenApp), ``, cmdSearch, "-i", "-q")
})
t.Run("SearchUpgradable", func(t *testing.T) {
mustSucceed(t, regexp.QuoteMeta(chosenApp), ``, cmdSearch, "-u", "-q")
})
t.Run("InstallUpgrade", func(t *testing.T) {
mustSucceed(t, `Installing `+regexp.QuoteMeta(chosenApp), ``,
cmdInstall, chosenApp)
})
t.Run("SearchUpgradableUpToDate", func(t *testing.T) {
mustSucceed(t, ``, regexp.QuoteMeta(chosenApp), cmdSearch, "-u", "-q")
})
t.Run("InstallUpToDate", func(t *testing.T) {
mustSucceed(t, `is up to date$`, ``, cmdInstall, chosenApp)
})
t.Run("UninstallExisting", func(t *testing.T) {
mustSucceed(t, `Uninstalling `+regexp.QuoteMeta(chosenApp), ``,
cmdUninstall, chosenApp)
})
}
func mustRun(t *testing.T, success bool, wantRe, negRe string, cmd *Command, args ...string) {
var buf bytes.Buffer
stdout, stderr = &buf, &buf
err := cmd.Run(args)
out := buf.String()
if err != nil {
out += err.Error()
}
if success && err != nil {
t.Fatalf("unexpected error: %v\n%s", err, out)
} else if !success && err == nil {
t.Fatalf("expected error, got none\n%s", out)
}
// Let '.' match newlines, and treat the output as a single line.
wantRe = "(?sm)" + wantRe
if !regexp.MustCompile(wantRe).MatchString(out) {
t.Fatalf("output does not match %#q:\n%s", wantRe, out)
}
if negRe != "" {
negRe = "(?sm)" + negRe
if regexp.MustCompile(negRe).MatchString(out) {
t.Fatalf("output does match %#q:\n%s", negRe, out)
}
}
}
......@@ -6,13 +6,13 @@ package main
import (
"fmt"
"github.com/mvdan/fdroidcl"
"github.com/mvdan/fdroidcl/adb"
"mvdan.cc/fdroidcl"
"mvdan.cc/fdroidcl/adb"
)
var cmdInstall = &Command{
UsageLine: "install <appid...>",
Short: "Install an app",
Short: "Install or upgrade an app",
}
func init() {
......@@ -35,15 +35,30 @@ func runInstall(args []string) error {
if err != nil {
return err
}
var toInstall []*fdroidcl.App
for _, app := range apps {
if _, e := inst[app.ID]; e {
return fmt.Errorf("%s is already installed", app.ID)
p, e := inst[app.PackageName]
if !e {
// installing an app from scratch
toInstall = append(toInstall, app)
continue
}
suggested := app.SuggestedApk(device)
if suggested == nil {
return fmt.Errorf("no suitable APKs found for %s", app.PackageName)
}
if p.VersCode >= suggested.VersCode {
fmt.Fprintf(stdout, "%s is up to date\n", app.PackageName)
// app is already up to date
continue
}
// upgrading an existing app
toInstall = append(toInstall, app)
}
return downloadAndDo(apps, device, installApk)
return downloadAndDo(toInstall, device)
}
func downloadAndDo(apps []*fdroidcl.App, device *adb.Device, doApk func(*adb.Device, *fdroidcl.Apk, string) error) error {
func downloadAndDo(apps []*fdroidcl.App, device *adb.Device) error {
type downloaded struct {
apk *fdroidcl.Apk
path string
......@@ -52,7 +67,7 @@ func downloadAndDo(apps []*fdroidcl.App, device *adb.Device, doApk func(*adb.Dev
for i, app := range apps {
apk := app.SuggestedApk(device)
if apk == nil {
return fmt.Errorf("no suitable APKs found for %s", app.ID)
return fmt.Errorf("no suitable APKs found for %s", app.PackageName)
}
path, err := downloadApk(apk)
if err != nil {
......@@ -61,7 +76,7 @@ func downloadAndDo(apps []*fdroidcl.App, device *adb.Device, doApk func(*adb.Dev
toInstall[i] = downloaded{apk: apk, path: path}
}
for _, t := range toInstall {
if err := doApk(device, t.apk, t.path); err != nil {
if err := installApk(device, t.apk, t.path); err != nil {
return err
}
}
......@@ -69,7 +84,7 @@ func downloadAndDo(apps []*fdroidcl.App, device *adb.Device, doApk func(*adb.Dev
}
func installApk(device *adb.Device, apk *fdroidcl.Apk, path string) error {
fmt.Printf("Installing %s\n", apk.AppID)
fmt.Fprintf(stdout, "Installing %s\n", apk.AppID)
if err := device.Install(path); err != nil {
return fmt.Errorf("could not install %s: %v", apk.AppID, err)
}
......
......@@ -29,7 +29,7 @@ func runList(args []string) error {
switch args[0] {
case "categories":
for _, app := range apps {
for _, c := range app.Categs {
for _, c := range app.Categories {
values[c] = struct{}{}
}
}
......@@ -42,7 +42,7 @@ func runList(args []string) error {
}
sort.Strings(result)
for _, s := range result {
fmt.Println(s)
fmt.Fprintln(stdout, s)
}
return nil
}
......@@ -7,19 +7,20 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/mvdan/basedir"
"mvdan.cc/fdroidcl/basedir"
)
const cmdName = "fdroidcl"
var version = "v0.3.1"
const version = "v0.4.0"
func errExit(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format, a...)
fmt.Fprintf(stderr, format, a...)
os.Exit(1)
}
......@@ -31,7 +32,17 @@ func subdir(dir, name string) string {
return p
}
var (
stdout io.Writer = os.Stdout
stderr io.Writer = os.Stderr
testBasedir = ""
)
func mustCache() string {
if testBasedir != "" {
return subdir(testBasedir, "cache")
}
dir := basedir.Cache()
if dir == "" {
errExit("Could not determine cache dir\n")
......@@ -40,6 +51,9 @@ func mustCache() string {
}
func mustData() string {
if testBasedir != "" {
return subdir(testBasedir, "data")
}
dir := basedir.Data()
if dir == "" {
errExit("Could not determine data dir\n")
......@@ -101,9 +115,6 @@ type Command struct {
// Short is the short description.
Short string
// Flag is a set of flags specific to this command.
Flag flag.FlagSet
}
// Name returns the command's name: the first word in the usage line.
......@@ -116,21 +127,21 @@ func (c *Command) Name() string {
return name
}
func (c *Command) Usage() {
fmt.Fprintf(os.Stderr, "Usage: %s %s [-h]\n", cmdName, c.UsageLine)
func (c *Command) usage(flagSet *flag.FlagSet) {
fmt.Fprintf(stderr, "Usage: %s %s [-h]\n", cmdName, c.UsageLine)
anyFlags := false
c.Flag.VisitAll(func(f *flag.Flag) { anyFlags = true })
flagSet.VisitAll(func(f *flag.Flag) { anyFlags = true })
if anyFlags {
fmt.Fprintf(os.Stderr, "\nAvailable options:\n")
c.Flag.PrintDefaults()
fmt.Fprintf(stderr, "\nAvailable options:\n")
flagSet.PrintDefaults()
}
os.Exit(2)
}
func init() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [-h] <command> [<args>]\n\n", cmdName)
fmt.Fprintf(os.Stderr, "Available commands:\n")
fmt.Fprintf(stderr, "Usage: %s [-h] <command> [<args>]\n\n", cmdName)
fmt.Fprintf(stderr, "Available commands:\n")
maxUsageLen := 0
for _, c := range commands {
if len(c.UsageLine) > maxUsageLen {
......@@ -138,11 +149,11 @@ func init() {
}
}
for _, c := range commands {
fmt.Fprintf(os.Stderr, " %s%s %s\n", c.UsageLine,
fmt.Fprintf(stderr, " %s%s %s\n", c.UsageLine,
strings.Repeat(" ", maxUsageLen-len(c.UsageLine)), c.Short)
}
fmt.Fprintf(os.Stderr, "\nA specific version of an app can be selected by following the appid with an colon (:) and the version code of the app to select.\n")
fmt.Fprintf(os.Stderr, "\nUse %s <command> -h for more info\n", cmdName)
fmt.Fprintf(stderr, "\nA specific version of an app can be selected by following the appid with an colon (:) and the version code of the app to select.\n")
fmt.Fprintf(stderr, "\nUse %s <command> -h for more info\n", cmdName)
}
}
......@@ -151,23 +162,24 @@ var commands = []*Command{
cmdUpdate,
cmdSearch,
cmdShow,
cmdList,
cmdDevices,
cmdDownload,
cmdInstall,
cmdUpgrade,
cmdUninstall,
cmdDownload,
cmdDevices,
cmdList,
cmdDefaults,
{
UsageLine: "version",
Short: "Print version information",
Run: func(args []string) error {
if len(args) > 0 {
return fmt.Errorf("no arguments allowed")
}
fmt.Println(version)
return nil
},
cmdVersion,
}
var cmdVersion = &Command{
UsageLine: "version",
Short: "Print version information",
Run: func(args []string) error {
if len(args) > 0 {
return fmt.Errorf("no arguments allowed")
}
fmt.Fprintln(stdout, version)
return nil
},
}
......@@ -186,10 +198,7 @@ func main() {
continue
}
readConfig()
cmd.Flag.Usage = func() { cmd.Usage() }
cmd.Flag.Parse(args[1:])
args = cmd.Flag.Args()
if err := cmd.Run(args); err != nil {
if err := cmd.Run(args[1:]); err != nil {
errExit("%s: %v\n", cmdName, err)
}
return
......@@ -197,7 +206,7 @@ func main() {
switch cmdName {
default:
fmt.Fprintf(os.Stderr, "Unrecognised command '%s'\n\n", cmdName)
fmt.Fprintf(stderr, "Unrecognised command '%s'\n\n", cmdName)
flag.Usage()
os.Exit(2)
}
......
......@@ -4,14 +4,15 @@
package main