F-Droid desktop client
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

329 lines
7.3 KiB

/* Copyright (c) 2015, Daniel Martí <mvdan@mvdan.cc> */
/* See LICENSE for licensing information */
package main
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"sort"
"strings"
)
// Repo is an F-Droid repository holding apps and apks
type Repo struct {
Apps []App `xml:"application"`
}
type CommaList []string
func (cl *CommaList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var content string
if err := d.DecodeElement(&content, &start); err != nil {
return err
}
*cl = strings.Split(content, ",")
return nil
}
// App is an Android application
type App struct {
ID string `xml:"id"`
Name string `xml:"name"`
Summary string `xml:"summary"`
Desc string `xml:"desc"`
License string `xml:"license"`
Categs CommaList `xml:"categories"`
Website string `xml:"web"`
Source string `xml:"source"`
Tracker string `xml:"tracker"`
Donate string `xml:"donate"`
Bitcoin string `xml:"bitcoin"`
Litecoin string `xml:"litecoin"`
Dogecoin string `xml:"dogecoin"`
FlattrID string `xml:"flattr"`
Apks []Apk `xml:"package"`
CVName string `xml:"marketversion"`
CVCode uint `xml:"marketvercode"`
CurApk *Apk
}
// Apk is an Android package
type Apk struct {
VName string `xml:"version"`
VCode uint `xml:"versioncode"`
Size int `xml:"size"`
MinSdk int `xml:"sdkver"`
MaxSdk int `xml:"maxsdkver"`
ABIs CommaList `xml:"nativecode"`
}
func (app *App) prepareData() {
for _, apk := range app.Apks {
app.CurApk = &apk
if app.CVCode >= apk.VCode {
break
}
}
}
func (app *App) writeShort(w io.Writer) {
fmt.Fprintf(w, "%s | %s %s\n", app.ID, app.Name, app.CurApk.VName)
fmt.Fprintf(w, " %s\n", app.Summary)
}
func (app *App) writeDetailed(w io.Writer) {
p := func(title string, format string, args ...interface{}) {
if format == "" {
fmt.Fprintln(w, title)
} else {
fmt.Fprintf(w, "%s %s\n", title, fmt.Sprintf(format, args...))
}
}
p("Name :", "%s", app.Name)
p("Summary :", "%s", app.Summary)
p("Current Version :", "%s (%d)", app.CurApk.VName, app.CurApk.VCode)
p("Upstream Version :", "%s (%d)", app.CVName, app.CVCode)
p("License :", "%s", app.License)
if app.Categs != nil {
p("Categories :", "%s", strings.Join(app.Categs, ", "))
}
if app.Website != "" {
p("Website :", "%s", app.Website)
}
if app.Source != "" {
p("Source :", "%s", app.Source)
}
if app.Tracker != "" {
p("Tracker :", "%s", app.Tracker)
}
if app.Donate != "" {
p("Donate :", "%s", app.Donate)
}
if app.Bitcoin != "" {
p("Bitcoin :", "bitcoin:%s", app.Bitcoin)
}
if app.Litecoin != "" {
p("Litecoin :", "litecoin:%s", app.Litecoin)
}
if app.Dogecoin != "" {
p("Dogecoin :", "dogecoin:%s", app.Dogecoin)
}
if app.FlattrID != "" {
p("Flattr :", "https://flattr.com/thing/%s", app.FlattrID)
}
// p("Description :", "%s", app.Desc) // TODO: parse html, 80 column wrapping
fmt.Println()
p("Available Versions :", "")
for _, apk := range app.Apks {
fmt.Println()
p(" Name :", "%s (%d)", apk.VName, apk.VCode)
p(" Size :", "%d", apk.Size)
p(" MinSdk :", "%d", apk.MinSdk)
if apk.MaxSdk > 0 {
p(" MaxSdk :", "%d", apk.MaxSdk)
}
if apk.ABIs != nil {
p(" ABIs :", "%s", strings.Join(apk.ABIs, ", "))
}
}
}
var ErrNotModified = errors.New("etag matches, file was not modified")
func downloadEtag(url, path string) error {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
etagPath := path + "-etag"
if _, err := os.Stat(path); err == nil {
etag, _ := ioutil.ReadFile(etagPath)
req.Header.Add("If-None-Match", string(etag))
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
return ErrNotModified
}
jar, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
err = ioutil.WriteFile(path, jar, 0644)
err2 := ioutil.WriteFile(etagPath, []byte(resp.Header["Etag"][0]), 0644)
if err != nil {
return err
}
if err2 != nil {
return err2
}
return nil
}
const indexName = "index.jar"
func updateIndex() {
url := fmt.Sprintf("%s/%s", *repoURL, indexName)
log.Printf("Downloading %s", url)
err := downloadEtag(url, indexName)
if err == ErrNotModified {
log.Printf("Index is already up to date")
} else if err != nil {
log.Fatalf("Could not update index: %s", err)
}
}
func loadApps() map[string]App {
r, err := zip.OpenReader(indexName)
if err != nil {
log.Fatal(err)
}
defer r.Close()
buf := new(bytes.Buffer)
for _, f := range r.File {
if f.Name != "index.xml" {
continue
}
rc, err := f.Open()
if err != nil {
log.Fatal(err)
}
if _, err = io.Copy(buf, rc); err != nil {
log.Fatal(err)
}
rc.Close()
break
}
var repo Repo
if err := xml.Unmarshal(buf.Bytes(), &repo); err != nil {
log.Fatalf("Could not read xml: %s", err)
}
apps := make(map[string]App)
for i := range repo.Apps {
app := repo.Apps[i]
app.prepareData()
apps[app.ID] = app
}
return apps
}
func appMatches(fields []string, terms []string) bool {
for _, field := range fields {
for _, term := range terms {
if !strings.Contains(field, term) {
goto next
}
}
return true
next:
}
return false
}
func filterAppsSearch(apps *map[string]App, terms []string) {
for _, term := range terms {
term = strings.ToLower(term)
}
for appID, app := range *apps {
fields := []string{
strings.ToLower(app.ID),
strings.ToLower(app.Name),
strings.ToLower(app.Summary),
// strings.ToLower(app.Desc), // TODO remove html
}
if !appMatches(fields, terms) {
delete(*apps, appID)
}
}
}
type appList []App
func (al appList) Len() int { return len(al) }
func (al appList) Swap(i, j int) { al[i], al[j] = al[j], al[i] }
func (al appList) Less(i, j int) bool { return al[i].ID < al[j].ID }
func sortedApps(apps map[string]App) []App {
list := make(appList, 0, len(apps))
for appID := range apps {
list = append(list, apps[appID])
}
sort.Sort(list)
return list
}
var repoURL = flag.String("r", "https://f-droid.org/repo", "repository address")
func init() {
flag.Usage = func() {
p := func(args ...interface{}) {
fmt.Fprintln(os.Stderr, args...)
}
p("Usage: fdroidcl [-h] [-r <repo address>] <command> [<args>]")
p()
p("Available commands:")
p(" update Update the index")
p(" list List all available apps")
p(" search <term...> Search available apps")
p(" show <appid...> Show detailed info of an app")
}
}
func main() {
flag.Parse()
if flag.NArg() == 0 {
flag.Usage()
os.Exit(2)
}
cmd := flag.Args()[0]
args := flag.Args()[1:]
switch cmd {
case "update":
updateIndex()
case "list":
apps := loadApps()
for _, app := range sortedApps(apps) {
app.writeShort(os.Stdout)
}
case "search":
apps := loadApps()
filterAppsSearch(&apps, args)
for _, app := range sortedApps(apps) {
app.writeShort(os.Stdout)
}
case "show":
apps := loadApps()
for _, appID := range args {
app, e := apps[appID]
if !e {
log.Fatalf("Could not find app with ID '%s'", appID)
}
app.writeDetailed(os.Stdout)
}
default:
fmt.Fprintf(os.Stderr, "Unrecognised command '%s'\n\n", cmd)
flag.Usage()
os.Exit(2)
}
}