├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── main.go └── makefile /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | alfred-apple-app-search 3 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:4ee0fdce22e8f1653b7b0a3a743dcbce7542062bfc831835c216fdd8314eb59b" 6 | name = "github.com/deanishe/awgo" 7 | packages = [ 8 | ".", 9 | "fuzzy", 10 | "util", 11 | ] 12 | pruneopts = "UT" 13 | revision = "fda4e59dbe6936fed9f4864b8f45cd18ad2c933c" 14 | version = "v0.15.0" 15 | 16 | [[projects]] 17 | digest = "1:8029e9743749d4be5bc9f7d42ea1659471767860f0cdc34d37c3111bd308a295" 18 | name = "golang.org/x/text" 19 | packages = [ 20 | "internal/gen", 21 | "internal/triegen", 22 | "internal/ucd", 23 | "transform", 24 | "unicode/cldr", 25 | "unicode/norm", 26 | ] 27 | pruneopts = "UT" 28 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 29 | version = "v0.3.0" 30 | 31 | [solve-meta] 32 | analyzer-name = "dep" 33 | analyzer-version = 1 34 | input-imports = ["github.com/deanishe/awgo"] 35 | solver-name = "gps-cdcl" 36 | solver-version = 1 37 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [prune] 29 | go-tests = true 30 | unused-packages = true 31 | 32 | [[constraint]] 33 | name = "github.com/deanishe/awgo" 34 | version = "0.15.0" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Nick Comer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alfred app store search 2 | 3 | search the mac app store within alfred 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/signal" 15 | "path/filepath" 16 | "runtime" 17 | "strings" 18 | "sync" 19 | "syscall" 20 | "time" 21 | 22 | "github.com/deanishe/awgo" 23 | ) 24 | 25 | const star rune = '⭑' 26 | 27 | var client = &http.Client{ 28 | Timeout: time.Second * 5, 29 | } 30 | 31 | func debug(format string, a ...interface{}) { 32 | if os.Getenv("DEBUG") != "" { 33 | fmt.Fprintf(os.Stderr, format+"\n", a...) 34 | } 35 | } 36 | 37 | func md5hash(s string) string { 38 | h := md5.New() 39 | io.WriteString(h, s) 40 | sum := h.Sum(nil) 41 | return hex.EncodeToString(sum) 42 | } 43 | 44 | // false -> new file 45 | // true -> already exists 46 | func openFileIfNotExists(filename string) (*os.File, bool, error) { 47 | _, err := os.Stat(filename) 48 | if os.IsNotExist(err) { 49 | f, err := os.Create(filename) 50 | return f, false, err 51 | } 52 | if err != nil { 53 | return nil, false, err 54 | } 55 | return nil, true, err 56 | } 57 | 58 | func downloadAllImages(ctx context.Context, concurrency int, urls []string) []*aw.Icon { 59 | die := func(format string, a ...interface{}) { 60 | fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...) 61 | // yes, deferred function calls will run even if Goexit() is called 62 | // (https://play.golang.org/p/LZ5Mt6F1DQW) DONT CALL IN MAIN GO ROUTINE 63 | runtime.Goexit() 64 | } 65 | output := make([]*aw.Icon, len(urls)) 66 | var wg sync.WaitGroup 67 | sem := make(chan bool, concurrency) 68 | dl := func(i int, url string) { 69 | output[i] = aw.IconError 70 | filename := fmt.Sprintf( 71 | "%s/net.nkcmr.alfred-apple-app-search/%s.png", 72 | strings.TrimRight(os.TempDir(), "/"), 73 | md5hash(url), 74 | ) 75 | if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil { 76 | die(err.Error()) 77 | return 78 | } 79 | f, x, err := openFileIfNotExists(filename) 80 | if err != nil { 81 | die( 82 | "failed to create or open file for downloaded artwork: %s", 83 | err.Error(), 84 | ) 85 | return 86 | } 87 | defer func() { 88 | if f != nil { 89 | defer f.Close() 90 | } 91 | }() 92 | if !x { 93 | debug("downloading: %s to %s", url, filename) 94 | resp, err := client.Get(url) 95 | if err != nil { 96 | die("failed to request artwork: %s", err.Error()) 97 | return 98 | } 99 | defer resp.Body.Close() 100 | if _, err := io.Copy(f, resp.Body); err != nil { 101 | die("failed to download artwork: %s", err.Error()) 102 | return 103 | } 104 | } else { 105 | debug("file is cached (%s)", filename) 106 | } 107 | output[i] = &aw.Icon{ 108 | Type: aw.IconTypeImage, 109 | Value: filename, 110 | } 111 | } 112 | for i, u := range urls { 113 | wg.Add(1) 114 | go func(i int, u string) { 115 | defer func() { 116 | <-sem 117 | wg.Done() 118 | }() 119 | sem <- true 120 | dl(i, u) 121 | }(i, u) 122 | } 123 | wg.Wait() 124 | return output 125 | } 126 | 127 | func sigContext() context.Context { 128 | ctx, cancel := context.WithCancel(context.Background()) 129 | go func() { 130 | defer cancel() 131 | c := make(chan os.Signal) 132 | signal.Notify(c, syscall.SIGTERM, syscall.SIGINT) 133 | fmt.Printf("signal: %s\n", <-c) 134 | }() 135 | return ctx 136 | } 137 | 138 | func main() { 139 | defer func() { 140 | if r := recover(); r != nil { 141 | log.Printf("fatal error: %+v", r) 142 | os.Exit(1) 143 | } 144 | }() 145 | ctx := sigContext() 146 | url, err := url.ParseRequestURI( 147 | "https://itunes.apple.com/search?media=software&entity=macSoftware&limit=20", 148 | ) 149 | if err != nil { 150 | panic(err) 151 | } 152 | q := url.Query() 153 | q.Set("term", os.Args[1]) 154 | url.RawQuery = q.Encode() 155 | req, err := http.NewRequest("GET", url.String(), http.NoBody) 156 | if err != nil { 157 | panic(err) 158 | } 159 | debug("sending request: %s %s", req.Method, req.URL.String()) 160 | resp, err := client.Do(req.WithContext(ctx)) 161 | if err != nil { 162 | panic(err) 163 | } 164 | defer resp.Body.Close() 165 | if resp.StatusCode != http.StatusOK { 166 | panic(fmt.Errorf("non-ok status code returned (%d)", resp.StatusCode)) 167 | } 168 | var results struct { 169 | Results []struct { 170 | ID int64 `json:"trackId"` 171 | Name string `json:"trackName"` 172 | Artwork string `json:"artworkUrl512"` 173 | URL string `json:"trackViewUrl"` 174 | Rating float64 `json:"averageUserRating"` 175 | PriceFmt string `json:"formattedPrice"` 176 | NumRatings int `json:"userRatingCount"` 177 | } `json:"results"` 178 | } 179 | if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { 180 | panic(err) 181 | } 182 | debug("successfully downloaded results (%d results)", len(results.Results)) 183 | images := make([]string, len(results.Results)) 184 | fb := aw.NewFeedback() 185 | for i, res := range results.Results { 186 | item := new(aw.Item). 187 | Title(res.Name). 188 | Subtitle( 189 | fmt.Sprintf( 190 | "%s | %s(%d ratings)", 191 | res.PriceFmt, 192 | func() string { 193 | if res.Rating == float64(0) { 194 | return "" 195 | } 196 | return strings.Repeat(string(star), int(res.Rating)) + " " 197 | }(), 198 | res.NumRatings, 199 | ), 200 | ). 201 | Arg(fmt.Sprintf("macappstores://itunes.apple.com/app/id%d", res.ID)). 202 | Valid(true). 203 | IsFile(false) 204 | item.NewModifier(aw.ModAlt).Arg(res.URL).Valid(true).Subtitle("Open in browser") 205 | fb.Items = append(fb.Items, item) 206 | images[i] = res.Artwork 207 | } 208 | icons := downloadAllImages(ctx, runtime.NumCPU(), images) 209 | for i := range icons { 210 | fb.Items[i] = fb.Items[i].Icon(icons[i]) 211 | } 212 | json.NewEncoder(os.Stdout).Encode(fb) 213 | } 214 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | GO_SOURCES = $(shell ls *.go) 2 | 3 | alfred-apple-app-search: $(GO_SOURCES) 4 | GOOS=darwin go build -v \ 5 | -ldflags='-w -s' \ 6 | . 7 | --------------------------------------------------------------------------------