diff --git a/.gitignore b/.gitignore index 99006e7..0d02100 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index e8437a4..e9211cc 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/client/go.mod b/client/go.mod new file mode 100644 index 0000000..9feea72 --- /dev/null +++ b/client/go.mod @@ -0,0 +1,3 @@ +module ipa/IPAGoClient + +go 1.25.0 diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..f0ba737 --- /dev/null +++ b/client/main.go @@ -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 + } + + } +} diff --git a/main.go b/main.go index 27940ad..1454636 100644 --- a/main.go +++ b/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 diff --git a/models/download.go b/models/download.go index 36c7085..2eacae0 100644 --- a/models/download.go +++ b/models/download.go @@ -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"` }