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:
2026-03-10 05:33:41 +03:00
parent ab59d53e2c
commit f53e327ec2
6 changed files with 324 additions and 2 deletions

6
.gitignore vendored
View File

@ -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

View File

@ -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
View File

@ -0,0 +1,3 @@
module ipa/IPAGoClient
go 1.25.0

304
client/main.go Normal file
View 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
}
}
}

View File

@ -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

View File

@ -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"`
}