feat(client): add IPAGo client implementation
Add a new Go client implementation for interacting with the IPAGo server. The client includes functionality for authentication, searching for apps, and downloading apps. The client is structured to handle errors and retries, and includes progress reporting for downloads. The client is added to the .gitignore file and the README.md file is updated with installation instructions. The server-side code is also updated to include the Content-Length header for download progress reporting.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@ -206,9 +206,9 @@ cython_debug/
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
@ -225,3 +225,5 @@ __marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
IPAGoClient
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
# Пример установки
|
||||
|
||||
```bash
|
||||
git clone https://gitea.yuharan.ru/public/IPAGo.git
|
||||
|
||||
cd IPAGo
|
||||
|
||||
PORT=9001 CONTAINER_NAME=mrxtrojanicloudcom docker compose up -d --build
|
||||
```
|
||||
|
||||
3
client/go.mod
Normal file
3
client/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module ipa/IPAGoClient
|
||||
|
||||
go 1.25.0
|
||||
304
client/main.go
Normal file
304
client/main.go
Normal file
@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func authInfo(host string, port int, user, pswd string) bool {
|
||||
linkAuthInfo := fmt.Sprintf(
|
||||
"http://%s:%d/auth/info?user=%s&pswd=%v", host, port, user, pswd,
|
||||
)
|
||||
urlAuthInfo, err := url.Parse(linkAuthInfo)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
resp, err := http.Get(urlAuthInfo.String())
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
var resultAuthInfo map[string]any
|
||||
err = json.Unmarshal(body, &resultAuthInfo)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func authLogin(host string, port int, user, pswd string) bool {
|
||||
linkAuthLogin := fmt.Sprintf(
|
||||
"http://%s:%d/auth/login?user=%s&pswd=%v", host, port, user, pswd,
|
||||
)
|
||||
|
||||
urlAuthLogin, err := url.Parse(linkAuthLogin)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.Get(urlAuthLogin.String())
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
return false
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
return false
|
||||
}
|
||||
|
||||
success, ok := result["success"].(bool)
|
||||
if !ok || !success {
|
||||
fmt.Println("login failed, retrying...", result)
|
||||
time.Sleep(10 * time.Second)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Println("login successful")
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
type App struct {
|
||||
BundleID string `json:"bundleID"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func search(host string, port int, query string, limit int) ([]App, error) {
|
||||
linkSearch := fmt.Sprintf(
|
||||
"http://%s:%d/search?query=%s&limit=%d", host, port, query, limit,
|
||||
)
|
||||
urlSearch, err := url.Parse(linkSearch)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.Get(urlSearch.String())
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return nil, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return nil, err
|
||||
}
|
||||
var resultSearch map[string]any
|
||||
err = json.Unmarshal(body, &resultSearch)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return nil, err
|
||||
}
|
||||
apps, ok := resultSearch["apps"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to get apps")
|
||||
}
|
||||
appsList := make([]App, len(apps))
|
||||
for i, app := range apps {
|
||||
app, ok := app.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to get app")
|
||||
}
|
||||
appsList[i] = App{
|
||||
BundleID: app["bundleID"].(string),
|
||||
Version: app["version"].(string),
|
||||
}
|
||||
}
|
||||
return appsList, nil
|
||||
}
|
||||
|
||||
type WriteCounter struct {
|
||||
Total int64
|
||||
Downloaded int64
|
||||
}
|
||||
|
||||
func (wc *WriteCounter) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
wc.Downloaded += int64(n)
|
||||
wc.PrintProgress()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (wc *WriteCounter) PrintProgress() {
|
||||
const MB = 1024 * 1024
|
||||
downloadedMB := float64(wc.Downloaded) / MB
|
||||
if wc.Total <= 0 {
|
||||
fmt.Printf("\rDownloading... %.2f MB", downloadedMB)
|
||||
return
|
||||
}
|
||||
totalMB := float64(wc.Total) / MB
|
||||
percentage := float64(wc.Downloaded) / float64(wc.Total) * 100
|
||||
fmt.Printf("\rDownloading... %.2f%% (%.2f/%.2f MB)", percentage, downloadedMB, totalMB)
|
||||
}
|
||||
|
||||
func download(host string, port int, bundleID, query, version, udid string) bool {
|
||||
linkDownload := fmt.Sprintf(
|
||||
"http://%s:%d/download?bundleID=%s", host, port, bundleID,
|
||||
)
|
||||
|
||||
urlDownload, err := url.Parse(linkDownload)
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
resp, err := http.Get(urlDownload.String())
|
||||
if err != nil {
|
||||
fmt.Println("error", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("error: server returned status %s\n", resp.Status)
|
||||
return false
|
||||
}
|
||||
|
||||
fileName := filepath.Join("apps", fmt.Sprintf("%s_%s_%s.ipa", udid, query, version))
|
||||
out, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
fmt.Println("error creating file:", err)
|
||||
return false
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
counter := &WriteCounter{Total: resp.ContentLength}
|
||||
_, err = io.Copy(out, io.TeeReader(resp.Body, counter))
|
||||
if err != nil {
|
||||
fmt.Println("\nerror downloading:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Print("\n") // New line after download completion
|
||||
return true
|
||||
}
|
||||
|
||||
func checkVersionInDownloaded(udid, query, version string) bool {
|
||||
path := filepath.Join("apps", fmt.Sprintf("%s_%s_%s.ipa", udid, query, version))
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.MkdirAll("apps", 0755)
|
||||
|
||||
bundleID := flag.String("b", "com.zhiliaoapp.musically", "Bundle ID of the app to download from IPAGo server")
|
||||
host := flag.String("h", "", "Host IPAGo server")
|
||||
port := flag.Int("p", 0, "Port IPAGo server")
|
||||
udid := flag.String("u", "", "UDID of the device to download the app to")
|
||||
user := flag.String("user", "", "User to login to IPAGo server")
|
||||
pswd := flag.String("pswd", "", "Password to login to IPAGo server")
|
||||
query := flag.String("q", "", "Query to search for apps on IPAGo server (example: TikTok)")
|
||||
limit := flag.Int("l", 1, "Limit of apps to search for on IPAGo server")
|
||||
flag.Parse()
|
||||
|
||||
if *host == "" {
|
||||
fmt.Println("host is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
if *port == 0 {
|
||||
fmt.Println("port is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
if *udid == "" {
|
||||
fmt.Println("UDID is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
if *user == "" {
|
||||
fmt.Println("user is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
if *pswd == "" {
|
||||
fmt.Println("password is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
if *query == "" {
|
||||
fmt.Println("query is required")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
userQuery := url.QueryEscape(*user)
|
||||
pswdQuery := url.QueryEscape(*pswd)
|
||||
|
||||
for {
|
||||
if !authInfo(*host, *port, userQuery, pswdQuery) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !authLogin(*host, *port, userQuery, pswdQuery) {
|
||||
continue
|
||||
}
|
||||
|
||||
searchResult, err := search(*host, *port, *query, *limit)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: handle multiple apps with the same bundleID
|
||||
srFirst := searchResult[0]
|
||||
if srFirst.BundleID != *bundleID {
|
||||
fmt.Println("bundleID mismatch, expected", *bundleID, "got", srFirst.BundleID)
|
||||
time.Sleep(120 * time.Minute)
|
||||
continue
|
||||
}
|
||||
|
||||
if checkVersionInDownloaded(*udid, *query, srFirst.Version) {
|
||||
fmt.Println("version already downloaded, skipping...")
|
||||
time.Sleep(120 * time.Minute)
|
||||
continue
|
||||
}
|
||||
|
||||
if !download(*host, *port, srFirst.BundleID, *query, srFirst.Version, *udid) {
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
6
main.go
6
main.go
@ -94,6 +94,12 @@ func main() {
|
||||
os.Remove(outputPath)
|
||||
fmt.Println("File removed after download")
|
||||
}()
|
||||
|
||||
// Set Content-Length header so the client can show progress
|
||||
if info, err := file.Stat(); err == nil {
|
||||
hctx.SetHeader("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||
}
|
||||
|
||||
_, _ = io.Copy(hctx.BodyWriter(), file)
|
||||
}
|
||||
return output, nil
|
||||
|
||||
@ -10,4 +10,5 @@ type DownloadOutput struct {
|
||||
Body func(huma.Context)
|
||||
ContentDisposition string `header:"Content-Disposition"`
|
||||
ContentType string `header:"Content-Type"`
|
||||
ContentLength int64 `header:"Content-Length"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user