diff --git a/doc/config.yaml.example b/doc/config.yaml.example index f70ca96..c16b143 100644 --- a/doc/config.yaml.example +++ b/doc/config.yaml.example @@ -1,3 +1,4 @@ server: - log: /data/app.log work_dir: /data +log: + log_file: /data/app.log diff --git a/go.mod b/go.mod index 1ec6cb4..3b5cfce 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/godbus/dbus/v5 v5.0.4 github.com/gofiber/contrib/websocket v1.0.0 github.com/gofiber/fiber/v2 v2.46.0 + github.com/gookit/event v1.1.2 github.com/grandcat/zeroconf v1.0.0 github.com/holoplot/go-avahi v1.0.1 github.com/iineva/CgbiPngFix v0.0.0-20210523041253-b8869b346914 diff --git a/go.sum b/go.sum index 7df8af9..e56b6bc 100644 --- a/go.sum +++ b/go.sum @@ -210,6 +210,10 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/event v1.1.2 h1:cYZWKJeoJWnP1ZxW1G+36GViV+hH9ksEorLqVw901Nw= +github.com/gookit/event v1.1.2/go.mod h1:YIYR3fXnwEq1tey3JfepMt19Mzm2uxmqlpc7Dj6Ekng= +github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -560,6 +564,7 @@ github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/internal/manager/install.go b/internal/manager/install.go new file mode 100644 index 0000000..a66ee08 --- /dev/null +++ b/internal/manager/install.go @@ -0,0 +1,148 @@ +package manager + +import ( + "context" + "io" + "os/exec" + "path/filepath" + "time" + + "github.com/bitxeno/atvloadly/internal/app" + "github.com/bitxeno/atvloadly/internal/log" + "github.com/gookit/event" +) + +type InstallManager struct { + quietMode bool + + outputStdout *outputWriter + outputStderr *outputWriter + + stdin io.WriteCloser + + cancel context.CancelFunc + em *event.Manager +} + +func NewInstallManager() *InstallManager { + em := event.NewManager("output", event.UsePathMode) + return &InstallManager{ + quietMode: true, + outputStdout: newOutputWriter(em), + outputStderr: newOutputWriter(em), + + em: em, + } +} + +func NewInteractiveInstallManager() *InstallManager { + ins := NewInstallManager() + ins.quietMode = false + return ins +} + +func (t *InstallManager) TryStart(ctx context.Context, udid, account, password, ipaPath string) error { + err := t.Start(ctx, udid, account, password, ipaPath) + if err != nil { + // AppleTV system has reboot/lockdownd sleep, try restart usbmuxd to fix + // LOCKDOWN_E_MUX_ERROR / AFC_E_MUX_ERROR / + ipaName := filepath.Base(ipaPath) + log.Infof("Try restarting usbmuxd to fix afc connect issue. %s", ipaName) + if err = RestartUsbmuxd(); err == nil { + log.Infof("Restart usbmuxd complete, try install ipa again. %s", ipaName) + time.Sleep(5 * time.Second) + err = t.Start(ctx, udid, account, password, ipaPath) + } + } + return err +} + +func (t *InstallManager) Start(ctx context.Context, udid, account, password, ipaPath string) error { + // set execute timeout 5 miniutes + timeout := 5 * time.Minute + ctx, cancel := context.WithTimeout(ctx, timeout) + t.cancel = cancel + + cmd := exec.CommandContext(ctx, "sideloader", "install", "--quiet", "--nocolor", "--udid", udid, "-a", account, "-p", password, ipaPath) + if !t.quietMode { + cmd = exec.CommandContext(ctx, "sideloader", "install", "--nocolor", "--udid", udid, "-a", account, "-p", password, ipaPath) + } + cmd.Dir = app.Config.Server.DataDir + cmd.Env = []string{"SIDELOADER_CONFIG_DIR=" + app.SideloaderDataDir()} + cmd.Stdout = t.outputStdout + cmd.Stderr = t.outputStderr + + var err error + t.stdin, err = cmd.StdinPipe() + if err != nil { + log.Err(err).Msg("Error creating stdin pipe: ") + return err + } + defer t.stdin.Close() + + if err := cmd.Start(); err != nil { + if err == context.DeadlineExceeded { + _ = cmd.Process.Kill() + } + log.Err(err).Msg("Error start installation script.") + return err + } + + err = cmd.Wait() + if err != nil { + log.Err(err).Msgf("Error executing installation script. %s", t.ErrorLog()) + } + return err +} + +func (t *InstallManager) Close() { + if t.cancel != nil { + t.cancel() + t.cancel = nil + } + if t.em != nil { + t.em.CloseWait() + } +} + +func (t *InstallManager) OnOutput(fn func(string)) { + t.em.On("output", event.ListenerFunc(func(e event.Event) error { + fn(e.Get("text").(string)) + return nil + })) +} + +func (t *InstallManager) Write(p []byte) { + _, _ = t.stdin.Write(p) +} + +func (t *InstallManager) ErrorLog() string { + return t.outputStderr.String() +} + +func (t *InstallManager) OutputLog() string { + return t.outputStdout.String() +} + +type outputWriter struct { + data []byte + em *event.Manager +} + +func newOutputWriter(em *event.Manager) *outputWriter { + return &outputWriter{ + em: em, + } +} + +func (w *outputWriter) Write(p []byte) (n int, err error) { + w.data = append(w.data, p...) + w.em.MustFire("output", event.M{"text": string(p)}) + + n = len(p) + return n, nil +} + +func (w *outputWriter) String() string { + return string(w.data) +} diff --git a/internal/manager/lockdown.go b/internal/manager/lockdown.go index 6b340d1..b2e0474 100644 --- a/internal/manager/lockdown.go +++ b/internal/manager/lockdown.go @@ -13,7 +13,6 @@ import ( ) func loadLockdownDevices() (map[string]model.LockdownDevice, error) { - log.Infof("Load lockdown from path: %s", app.Config.App.LockdownDir) files, err := os.ReadDir(app.Config.App.LockdownDir) if err != nil { log.Err(err).Msg("Read lockdown dir error: ") diff --git a/internal/manager/pair.go b/internal/manager/pair.go new file mode 100644 index 0000000..22f72c5 --- /dev/null +++ b/internal/manager/pair.go @@ -0,0 +1,118 @@ +package manager + +import ( + "context" + "io" + "os/exec" + "time" + + "github.com/bitxeno/atvloadly/internal/app" + "github.com/bitxeno/atvloadly/internal/log" + "github.com/gookit/event" +) + +type PairManager struct { + outputStdout *pairOutputWriter + outputStderr *pairOutputWriter + + stdin io.WriteCloser + + cancel context.CancelFunc + em *event.Manager +} + +func NewPairManager() *PairManager { + em := event.NewManager("output", event.UsePathMode) + return &PairManager{ + outputStdout: newPairOutputWriter(em), + outputStderr: newPairOutputWriter(em), + + em: em, + } +} + +func (t *PairManager) Start(ctx context.Context, udid string) error { + // set execute timeout 1 miniutes + timeout := time.Minute + ctx, cancel := context.WithTimeout(ctx, timeout) + t.cancel = cancel + + cmd := exec.CommandContext(ctx, "idevicepair", "pair", "-u", udid, "-w") + cmd.Dir = app.Config.Server.DataDir + cmd.Stdout = t.outputStdout + cmd.Stderr = t.outputStderr + + var err error + t.stdin, err = cmd.StdinPipe() + if err != nil { + log.Err(err).Msg("Error creating stdin pipe: ") + return err + } + defer t.stdin.Close() + + if err := cmd.Start(); err != nil { + if err == context.DeadlineExceeded { + _ = cmd.Process.Kill() + } + log.Err(err).Msg("Error start pair script.") + return err + } + + err = cmd.Wait() + if err != nil { + log.Err(err).Msgf("Error executing pair script. %s", t.ErrorLog()) + } + return err +} + +func (t *PairManager) Close() { + if t.cancel != nil { + t.cancel() + t.cancel = nil + } + if t.em != nil { + t.em.CloseWait() + } +} + +func (t *PairManager) OnOutput(fn func(string)) { + t.em.On("output", event.ListenerFunc(func(e event.Event) error { + fn(e.Get("text").(string)) + return nil + })) +} + +func (t *PairManager) Write(p []byte) { + _, _ = t.stdin.Write(p) +} + +func (t *PairManager) ErrorLog() string { + return t.outputStderr.String() +} + +func (t *PairManager) OutputLog() string { + return t.outputStdout.String() +} + +type pairOutputWriter struct { + data []byte + em *event.Manager +} + +func newPairOutputWriter(em *event.Manager) *pairOutputWriter { + return &pairOutputWriter{ + em: em, + } +} + +func (w *pairOutputWriter) Write(p []byte) (n int, err error) { + w.data = append(w.data, p...) + w.em.MustFire("output", event.M{"text": string(p)}) + + n = len(p) + return n, nil +} + +func (w *pairOutputWriter) String() string { + return string(w.data) +} diff --git a/internal/model/message.go b/internal/model/message.go new file mode 100644 index 0000000..dc807a9 --- /dev/null +++ b/internal/model/message.go @@ -0,0 +1,18 @@ +package model + +import "encoding/json" + +// Message.Type +const ( + MessageTypeInstall = 1 + MessageType2FA = 2 + + MessageTypePair = 1 + MessageTypePairConfirm = 2 +) + +// Message Websocket Communication data format +type Message struct { + Type int `json:"t"` + Data json.RawMessage `json:"d"` +} diff --git a/internal/service/websocket.go b/internal/service/websocket.go new file mode 100644 index 0000000..707e637 --- /dev/null +++ b/internal/service/websocket.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/bitxeno/atvloadly/internal/log" + "github.com/bitxeno/atvloadly/internal/manager" + "github.com/bitxeno/atvloadly/internal/model" + "github.com/gofiber/contrib/websocket" +) + +func HandleInstallMessage(c *websocket.Conn) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + installMgr := manager.NewInteractiveInstallManager() + installMgr.OnOutput(func(line string) { + _ = c.WriteMessage(websocket.TextMessage, []byte(line)) + }) + defer installMgr.Close() + + for { + var msg model.Message + if err := c.ReadJSON(&msg); err != nil { + // websocket client close + if websocket.IsUnexpectedCloseError(err) || websocket.IsCloseError(err) { + return + } + log.Err(err).Msg("Read websocket message error: ") + return + } + var data string + if err := json.Unmarshal(msg.Data, &data); err != nil { + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) + return + } + + switch msg.Type { + case model.MessageTypeInstall: + var v model.InstalledApp + err := json.Unmarshal([]byte(data), &v) + if err != nil { + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) + continue + } + + if v.Account == "" || v.Password == "" || v.UDID == "" { + _ = c.WriteMessage(websocket.TextMessage, []byte("account or password or UDID is empty")) + continue + } + + go runInstallMessage(ctx, c, installMgr, v) + case model.MessageType2FA: + code := data + installMgr.Write([]byte(code + "\n")) + default: + _ = c.WriteMessage(websocket.TextMessage, []byte("ERROR: invalid message type")) + continue + } + } +} + +func runInstallMessage(ctx context.Context, c *websocket.Conn, installMgr *manager.InstallManager, v model.InstalledApp) { + err := installMgr.Start(ctx, v.UDID, v.Account, v.Password, v.IpaPath) + if err != nil { + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) + return + } + log.Infof("install exit: %s", v.IpaPath) +} + +func HandlePairMessage(c *websocket.Conn) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pairMgr := manager.NewPairManager() + pairMgr.OnOutput(func(line string) { + _ = c.WriteMessage(websocket.TextMessage, []byte(line)) + }) + defer pairMgr.Close() + + for { + var msg model.Message + if err := c.ReadJSON(&msg); err != nil { + // websocket client close + if websocket.IsUnexpectedCloseError(err) || websocket.IsCloseError(err) { + return + } + log.Err(err).Msg("Read websocket message error: ") + return + } + var data string + if err := json.Unmarshal(msg.Data, &data); err != nil { + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) + return + } + + switch msg.Type { + case model.MessageTypePair: + udid := data + go runPairMessage(ctx, c, pairMgr, udid) + case model.MessageTypePairConfirm: + code := data + pairMgr.Write([]byte(code + "\n")) + default: + _ = c.WriteMessage(websocket.TextMessage, []byte("ERROR: invalid message type")) + continue + } + } +} + +func runPairMessage(ctx context.Context, c *websocket.Conn, pairMgr *manager.PairManager, udid string) { + err := pairMgr.Start(ctx, udid) + if err != nil { + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) + return + } +} diff --git a/internal/task/task.go b/internal/task/task.go index d076592..86dfbcc 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -1,12 +1,9 @@ package task import ( - "bufio" - "bytes" + "context" "fmt" - "io" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -135,21 +132,7 @@ func (t *Task) StartInstallApp(v model.InstalledApp) { func (t *Task) tryInstallApp(v model.InstalledApp) { log.Infof("Start installing ipa: %s", v.IpaName) - var err error - // AppleTV system has reboot/lockdownd sleep, try restart usbmuxd to fix - // LOCKDOWN_E_MUX_ERROR / AFC_E_MUX_ERROR / - err = manager.CheckAfcServiceStatus(v.UDID) - if err != nil { - log.Err(err).Msgf("Afc service can't connect. %s", v.IpaName) - log.Infof("Try restarting usbmuxd to fix afc connect issue. %s", v.IpaName) - if err = manager.RestartUsbmuxd(); err == nil { - log.Infof("Restart usbmuxd complete, try install ipa again. %s", v.IpaName) - time.Sleep(5 * time.Second) - err = t.runInternal(v) - } - } else { - err = t.runInternal(v) - } + err := t.runInternal(v) now := time.Now() if err == nil { @@ -176,90 +159,28 @@ func (t *Task) runInternal(v model.InstalledApp) error { return fmt.Errorf("account or password or UDID is empty") } - // The sideloader will handle special character "$". For those with this special character, it needs to be enclosed in single quotation marks. - cmd := exec.Command("sideloader", "install", "--quiet", "--nocolor", "--udid", v.UDID, "-a", v.Account, "-p", v.Password, v.IpaPath) - cmd.Dir = app.Config.Server.DataDir - cmd.Env = []string{"SIDELOADER_CONFIG_DIR=" + app.SideloaderDataDir()} - stdin, err := cmd.StdinPipe() + installMgr := manager.NewInstallManager() + defer installMgr.Close() + err := installMgr.TryStart(context.Background(), v.UDID, v.Account, v.Password, v.IpaPath) if err != nil { - log.Err(err).Msg("Error obtaining stdin: ") return err } - stdout, err := cmd.StdoutPipe() - if err != nil { - log.Err(err).Msg("Error obtaining stdout: ") - return err - } - stderr, err := cmd.StderrPipe() - if err != nil { - log.Err(err).Msg("Error obtaining stdout: ") - return err - } - - var output strings.Builder - var outputErr strings.Builder - reader := bufio.NewReader(stdout) - readerErr := bufio.NewReader(stderr) - go func(reader io.Reader) { - defer stdin.Close() - scanner := bufio.NewScanner(reader) - - for scanner.Scan() { - lineText := scanner.Text() - _, _ = output.WriteString(lineText) - _, _ = output.WriteString("\n") - - // Processing interaction to continue, such as [the Installing AltStore with Multiple AltServers the Not Supported] message. - if strings.Contains(lineText, "Press any key to continue") { - _, _ = stdin.Write([]byte("\n")) - } - } - }(reader) - go func(reader io.Reader) { - defer stdin.Close() - scanner := bufio.NewScanner(reader) - - for scanner.Scan() { - lineText := scanner.Text() - _, _ = output.WriteString(lineText) - _, _ = output.WriteString("\n") - - _, _ = outputErr.WriteString(lineText) - _, _ = outputErr.WriteString("\n") - - // Processing interaction to continue, such as [the Installing AltStore with Multiple AltServers the Not Supported] message. - if strings.Contains(lineText, "Press any key to continue") { - _, _ = stdin.Write([]byte("\n")) - } - } - }(readerErr) - if err := cmd.Start(); nil != err { - data := []byte(output.String()) - t.writeLog(v, data) - log.Err(err).Msgf("Error executing installation script. %s", outputErr.String()) - return fmt.Errorf("%s %v", outputErr.String(), err) - } - err = cmd.Wait() + t.writeLog(v, installMgr.OutputLog()) if err != nil { - data := []byte(output.String()) - t.writeLog(v, data) - log.Err(err).Msgf("Error executing installation script. %s", outputErr.String()) - return fmt.Errorf("%s %v", outputErr.String(), err) + log.Err(err).Msgf("Error executing installation script. %s", installMgr.ErrorLog()) + return err } - - data := []byte(output.String()) - t.writeLog(v, data) - if strings.Contains(string(data), "Installation Succeeded") { + if strings.Contains(installMgr.OutputLog(), "Installation Succeeded") { return nil } else { - return fmt.Errorf(outputErr.String()) + return fmt.Errorf(installMgr.ErrorLog()) } } -func (t *Task) writeLog(v model.InstalledApp, data []byte) { +func (t *Task) writeLog(v model.InstalledApp, data string) { // Hide log password string - data = bytes.Replace(data, []byte(v.Password), []byte("******"), -1) + data = strings.Replace(data, v.Password, "******", -1) saveDir := filepath.Join(app.Config.Server.DataDir, "log") if err := os.MkdirAll(saveDir, os.ModePerm); err != nil { @@ -268,7 +189,7 @@ func (t *Task) writeLog(v model.InstalledApp, data []byte) { } path := filepath.Join(saveDir, fmt.Sprintf("task_%d.log", v.ID)) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := os.WriteFile(path, []byte(data), 0644); err != nil { log.Error("write task log failed :" + path) return } diff --git a/web/router.go b/web/router.go index 296d3f7..a9ae552 100644 --- a/web/router.go +++ b/web/router.go @@ -39,7 +39,8 @@ func route(fi *fiber.App) { fi.Get("/ws/tty", websocket.New(func(c *websocket.Conn) { term, err := tty.New(c, "bash") if err != nil { - _ = c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + msg := fmt.Sprintf("ERROR: %s", err.Error()) + _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) return } defer term.Close() @@ -48,6 +49,8 @@ func route(fi *fiber.App) { term.SetENV([]string{fmt.Sprintf("SIDELOADER_CONFIG_DIR='%s'", app.SideloaderDataDir())}) term.Start() })) + fi.Get("/ws/pair", websocket.New(service.HandlePairMessage)) + fi.Get("/ws/install", websocket.New(service.HandleInstallMessage)) fi.Get("/apps/:id/icon", func(c *fiber.Ctx) error { id := utils.MustParseInt(c.Params("id")) @@ -145,7 +148,7 @@ func route(fi *fiber.App) { id := c.Params("id") if err := service.CheckAfcService(c.Context(), id); err != nil { - return c.Status(http.StatusOK).JSON(apiSuccess(err.Error())) + return c.Status(http.StatusOK).JSON(apiError(err.Error())) } else { return c.Status(http.StatusOK).JSON(apiSuccess("success")) } diff --git a/web/static/src/api/api.js b/web/static/src/api/api.js index 2184e97..6eb2c42 100644 --- a/web/static/src/api/api.js +++ b/web/static/src/api/api.js @@ -74,14 +74,21 @@ export default { }, upload: (data) => { - return request({ - url: "/api/upload", - method: "post", - timeout: 300000, - headers: { - "Content-Type": "multipart/form-data", - }, - data, + return new Promise((resolve, reject) => { + request({ + url: "/api/upload", + method: "post", + timeout: 300000, + headers: { + "Content-Type": "multipart/form-data", + }, + data, + }) .then((res) => { + resolve(res.data); + }) + .catch((err) => { + reject(err); + }); }); }, diff --git a/web/static/src/page/home/index.vue b/web/static/src/page/home/index.vue index 83b9905..a427ec2 100644 --- a/web/static/src/page/home/index.vue +++ b/web/static/src/page/home/index.vue @@ -152,8 +152,7 @@ -