diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index 6fe669c..af45de4 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -39,6 +39,7 @@ func Run(a fyne.App, version string) { ui, loadErr := build(a, version) ui.win.Show() ui.flashCards() + ui.scheduleUpdateCheck() if loadErr != nil { dialog.ShowError(loadErr, ui.win) } diff --git a/internal/ui/update.go b/internal/ui/update.go index 0826261..9fbfcad 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -4,23 +4,169 @@ import ( "fmt" "os" "os/exec" + "time" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "lazyrun/internal/update" ) -// checkUpdates menjalankan AppImageUpdate kalau LazyRun berjalan sebagai AppImage. -func (ui *app) checkUpdates() { - image := os.Getenv("APPIMAGE") - if image == "" { - dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("not_appimage"), ui.version), ui.win) - return - } - update, err := exec.LookPath("AppImageUpdate") - if err != nil { - dialog.ShowInformation(T("check_updates"), T("no_appimageupdate"), ui.win) - return - } - if err := exec.Command(update, image).Start(); err != nil { - dialog.ShowError(err, ui.win) +// checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan. +func (ui *app) checkUpdates() { ui.checkForUpdate(true) } + +// scheduleUpdateCheck memeriksa rilis terbaru beberapa saat setelah aplikasi tampil. +func (ui *app) scheduleUpdateCheck() { + if os.Getenv("APPIMAGE") == "" { + return // dijalankan dari source: jangan ganggu dengan cek update } + time.AfterFunc(2*time.Second, func() { ui.checkForUpdate(false) }) +} + +// checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya +// memberitahu kalau memang ada versi baru. +func (ui *app) checkForUpdate(manual bool) { + if ui.version == "" || ui.version == "dev" { + if manual { + dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("dev_build"), ui.version), ui.win) + } + return + } + + go func() { + rel, err := update.Latest() + fyne.Do(func() { + switch { + case err != nil: + if manual { + dialog.ShowError(err, ui.win) + } + case update.Newer(ui.version, rel): + ui.showUpdateDialog(rel) + case manual: + dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("up_to_date"), ui.version), ui.win) + } + }) + }() +} + +// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti. +func (ui *app) showUpdateDialog(rel update.Release) { + notes := widget.NewLabel(rel.Notes) + notes.Wrapping = fyne.TextWrapWord + notes.Importance = widget.LowImportance + + body := container.NewVScroll(container.NewVBox( + heading(fmt.Sprintf(T("update_available"), rel.Version, ui.version)), + muted(T("update_hint")), + notes, + )) + + var d *dialog.CustomDialog + buttons := container.NewHBox( + logoBox(newLogo(), 20), + spacer(), + widget.NewButton(T("update_later"), func() { d.Hide() }), + widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }), + ) + + if updates := ui.updaterBinary(); updates != "" { + // AppImageUpdate yang memasang (dan memverifikasi) sendiri + buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { + d.Hide() + ui.runUpdater(updates, rel) + })) + } else { + buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { + d.Hide() + ui.downloadUpdate(rel) + })) + } + + d = dialog.NewCustomWithoutButtons(T("check_updates"), container.NewBorder(body, buttons, nil, nil), ui.win) + showDialog(d, ui.win, 560, 440) +} + +// updaterBinary mengembalikan path AppImageUpdate kalau terpasang. +func (ui *app) updaterBinary() string { + if os.Getenv("APPIMAGE") == "" { + return "" + } + path, err := exec.LookPath("AppImageUpdate") + if err != nil { + return "" + } + return path +} + +// downloadUpdate mengunduh AppImage terbaru dan menukarnya dengan yang sedang dipakai. +func (ui *app) downloadUpdate(rel update.Release) { + appImage := os.Getenv("APPIMAGE") + if appImage == "" { + ui.openURL(rel.PageURL) + return + } + + progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) + progress.Show() + go func() { + err := update.Download(rel, appImage) + fyne.Do(func() { + progress.Hide() + if err != nil { + dialog.ShowError(err, ui.win) + return + } + ui.offerRestart() + }) + }() +} + +// runUpdater memakai AppImageUpdate untuk memasang update (pakai zsync, unduhannya kecil). +func (ui *app) runUpdater(updater string, rel update.Release) { + progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) + progress.Show() + go func() { + err := exec.Command(updater, os.Getenv("APPIMAGE")).Run() + fyne.Do(func() { + progress.Hide() + if err != nil { + dialog.ShowError(err, ui.win) + return + } + ui.offerRestart() + }) + }() +} + +// offerRestart menawarkan restart supaya versi baru yang dipakai. +func (ui *app) offerRestart() { + var d *dialog.CustomDialog + content := container.NewVBox( + widget.NewLabel(T("update_installed")), + container.NewHBox(spacer(), + widget.NewButton(T("update_later"), func() { d.Hide() }), + widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart), + ), + ) + d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win) + showDialog(d, ui.win, 460, 200) +} + +// restart menjalankan AppImage yang baru lalu menutup proses lama. +func (ui *app) restart() { + appImage := os.Getenv("APPIMAGE") + if appImage == "" { + return + } + cmd := exec.Command(appImage) + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + dialog.ShowError(err, ui.win) + return + } + ui.win.Close() } diff --git a/internal/update/install.go b/internal/update/install.go new file mode 100644 index 0000000..43cb9a8 --- /dev/null +++ b/internal/update/install.go @@ -0,0 +1,21 @@ +package update + +import ( + "errors" + "io" +) + +// maxDownload membatasi ukuran unduhan (AppImage LazyRun ~12 MB). +const maxDownload = 300 << 20 + +// copyWithLimit menyalin sampai batas tertentu supaya unduhan tidak menghabiskan disk. +func copyWithLimit(dst io.Writer, src io.Reader, limit int64) (int64, error) { + n, err := io.Copy(dst, io.LimitReader(src, limit+1)) + if err != nil { + return n, err + } + if n > limit { + return n, errors.New("berkas rilis terlalu besar") + } + return n, nil +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..7d78704 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,229 @@ +// Package update memeriksa rilis terbaru LazyRun di GitHub dan memasangnya. +package update + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +var ( + apiBase = "https://api.github.com/repos/oxpoe/lazyrun/releases" + repoURL = "https://github.com/oxpoe/lazyrun" +) + +// LAZYRUN_UPDATE_API bisa dipakai untuk mengarahkan cek update ke server lain +// (mis. untuk tes atau build dari fork). +func init() { + if v := os.Getenv("LAZYRUN_UPDATE_API"); v != "" { + apiBase = v + } +} + +// SetEndpoint mengarahkan pemeriksaan rilis ke alamat lain (dipakai tes). +func SetEndpoint(base string) { + apiBase = base +} + +// RepoURL adalah halaman repo LazyRun. +func RepoURL() string { return repoURL } + +// Release adalah data rilis terbaru yang dibutuhkan LazyRun. +type Release struct { + Tag string // tag asli, mis. "v1.1.0" + Version string // versi tanpa awalan v, mis. "1.1.0" + Notes string // catatan rilis (markdown GitHub) + PageURL string // halaman rilis di GitHub + AppImage string // URL unduhan AppImage (kosong kalau tidak ada) + Zsync string // URL berkas .zsync (opsional) +} + +var client = &http.Client{Timeout: 20 * time.Second} + +// Latest mengambil rilis terbaru dari GitHub. +func Latest() (Release, error) { + var rel Release + req, err := http.NewRequest(http.MethodGet, apiBase+"/latest", nil) + if err != nil { + return rel, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "LazyRun") + + resp, err := client.Do(req) + if err != nil { + return rel, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return rel, fmt.Errorf("GitHub menjawab %s", resp.Status) + } + + var payload struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return rel, err + } + + rel = Release{ + Tag: payload.TagName, + Version: strings.TrimPrefix(payload.TagName, "v"), + Notes: strings.TrimSpace(payload.Body), + PageURL: payload.HTMLURL, + } + if rel.PageURL == "" { + rel.PageURL = repoURL + "/releases/latest" + } + for _, asset := range payload.Assets { + switch { + case strings.HasSuffix(asset.Name, ".AppImage"): + rel.AppImage = asset.URL + case strings.HasSuffix(asset.Name, ".AppImage.zsync"): + rel.Zsync = asset.URL + } + } + return rel, nil +} + +// Newer benar kalau rilis lebih baru dari versi yang sedang dipakai. +func Newer(current string, rel Release) bool { + return CompareVersions(rel.Version, current) > 0 +} + +// CompareVersions membandingkan dua versi sederhana (mis. "1.2.0" vs "1.10"): +// hasil -1 kalau a lebih lama, 0 sama, 1 lebih baru. Awalan "v" diabaikan, dan +// versi dengan akhiran (mis. 1.0.0-rc1) dianggap lebih lama dari versi final. +func CompareVersions(a, b string) int { + pa, sa := parseVersion(a) + pb, sb := parseVersion(b) + + for i := 0; i < len(pa) || i < len(pb); i++ { + var na, nb int + if i < len(pa) { + na = pa[i] + } + if i < len(pb) { + nb = pb[i] + } + if na != nb { + if na < nb { + return -1 + } + return 1 + } + } + if sa == sb { + return 0 + } + if sa == "" { + return 1 // versi final lebih baru dari versi bersuffix + } + if sb == "" { + return -1 + } + return strings.Compare(sa, sb) +} + +func parseVersion(v string) ([]int, string) { + v = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v")) + main, suffix, _ := strings.Cut(v, "-") + if plus := strings.IndexByte(main, '+'); plus >= 0 { + main = main[:plus] + } + + var numbers []int + for _, part := range strings.Split(main, ".") { + digits := part + if i := strings.IndexFunc(part, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 { + digits = part[:i] + } + n, err := strconv.Atoi(digits) + if err != nil { + n = 0 + } + numbers = append(numbers, n) + } + return numbers, suffix +} + +// IsAppImage memeriksa apakah sebuah berkas benar-benar AppImage (ELF dengan +// magic AI di offset 8). Dipakai sebelum menimpa AppImage yang sedang dipakai. +func IsAppImage(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + head := make([]byte, 12) + if _, err := f.Read(head); err != nil { + return false + } + if !(head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F') { + return false + } + return head[8] == 'A' && head[9] == 'I' && (head[10] == 0x01 || head[10] == 0x02) +} + +// Download mengganti AppImage tujuan dengan rilis terbaru. +// Unduhan ditulis ke berkas sementara di folder yang sama, diverifikasi, baru +// ditukar secara atomik: kalau gagal, aplikasi lama tetap utuh. +func Download(rel Release, target string) error { + if rel.AppImage == "" { + return errors.New("rilis ini tidak menyertakan AppImage") + } + if target == "" { + return errors.New("tidak tahu lokasi AppImage yang berjalan") + } + + req, err := http.NewRequest(http.MethodGet, rel.AppImage, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "LazyRun") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unduhan gagal: %s", resp.Status) + } + + tmp, err := os.CreateTemp(filepath.Dir(target), ".lazyrun-update-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + if _, err := copyWithLimit(tmp, resp.Body, maxDownload); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o755); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + if !IsAppImage(tmp.Name()) { + return errors.New("berkas yang diunduh bukan AppImage yang sah") + } + return os.Rename(tmp.Name(), target) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..ef48871 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,180 @@ +package update + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.0.0", "1.0.0", 0}, + {"v1.0.0", "1.0.0", 0}, + {"1.1.0", "1.0.0", 1}, + {"1.0.0", "1.1.0", -1}, + {"1.10.0", "1.9.0", 1}, + {"2.0", "1.9.9", 1}, + {"1.0", "1.0.0", 0}, + {"1.0.0-rc1", "1.0.0", -1}, + {"1.0.0", "1.0.0-rc1", 1}, + {"dev", "1.0.0", -1}, + } + for _, c := range cases { + if got := CompareVersions(c.a, c.b); got != c.want { + t.Errorf("CompareVersions(%q, %q) = %d, mau %d", c.a, c.b, got, c.want) + } + } +} + +func TestNewerIgnoresSameAndOlder(t *testing.T) { + if Newer("1.0.0", Release{Version: "1.0.0"}) { + t.Error("versi sama bukan update") + } + if Newer("1.0.0", Release{Version: "0.9.0"}) { + t.Error("versi lama bukan update") + } + if !Newer("1.0.0", Release{Version: "1.0.1"}) { + t.Error("versi lebih baru harus terdeteksi") + } +} + +func TestLatestParsesRelease(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == "" { + t.Error("GitHub butuh header User-Agent") + } + _, _ = w.Write([]byte(`{ + "tag_name": "v1.2.0", + "body": " - ikon online\n - animasi ", + "html_url": "https://github.com/oxpoe/lazyrun/releases/tag/v1.2.0", + "assets": [ + {"name": "lazyrun-1.2.0-x86_64.AppImage.zsync", "browser_download_url": "https://contoh/zsync"}, + {"name": "lazyrun-1.2.0-x86_64.AppImage", "browser_download_url": "https://contoh/appimage"} + ] + }`)) + })) + defer srv.Close() + + SetEndpoint(srv.URL) + t.Cleanup(func() { SetEndpoint("https://api.github.com/repos/oxpoe/lazyrun/releases") }) + + rel, err := Latest() + if err != nil { + t.Fatal(err) + } + if rel.Version != "1.2.0" || rel.Tag != "v1.2.0" { + t.Errorf("versi = %q / %q", rel.Version, rel.Tag) + } + if rel.AppImage != "https://contoh/appimage" || rel.Zsync != "https://contoh/zsync" { + t.Errorf("aset salah: %q %q", rel.AppImage, rel.Zsync) + } + if rel.Notes != "- ikon online\n - animasi" { + t.Errorf("catatan rilis = %q", rel.Notes) + } + if !Newer("1.1.9", rel) { + t.Error("1.2.0 harus dianggap lebih baru dari 1.1.9") + } +} + +func TestDownloadReplacesAppImageAtomically(t *testing.T) { + appImage := fakeAppImage(2048) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(appImage) + })) + defer srv.Close() + + target := filepath.Join(t.TempDir(), "lazyrun.AppImage") + if err := os.WriteFile(target, fakeAppImage(1024), 0o755); err != nil { + t.Fatal(err) + } + + if err := Download(Release{AppImage: srv.URL}, target); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(data) != string(appImage) { + t.Error("isi AppImage tidak terganti") + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Error("AppImage hasil update harus executable") + } + // tidak boleh menyisakan berkas sementara + entries, err := os.ReadDir(filepath.Dir(target)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("ada berkas sisa: %v", entries) + } +} + +func TestDownloadKeepsOldAppImageOnBadData(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("404")) + })) + defer srv.Close() + + target := filepath.Join(t.TempDir(), "lazyrun.AppImage") + old := fakeAppImage(512) + if err := os.WriteFile(target, old, 0o755); err != nil { + t.Fatal(err) + } + + if err := Download(Release{AppImage: srv.URL}, target); err == nil { + t.Fatal("berkas bukan AppImage harus ditolak") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(data) != string(old) { + t.Error("AppImage lama harus tetap utuh saat update gagal") + } +} + +func TestDownloadWithoutAsset(t *testing.T) { + target := filepath.Join(t.TempDir(), "lazyrun.AppImage") + if err := Download(Release{}, target); err == nil { + t.Error("rilis tanpa AppImage harus error") + } +} + +func TestIsAppImage(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good") + if err := os.WriteFile(good, fakeAppImage(16), 0o755); err != nil { + t.Fatal(err) + } + if !IsAppImage(good) { + t.Error("AppImage sah tidak dikenali") + } + + plain := filepath.Join(dir, "plain") + if err := os.WriteFile(plain, []byte("\x7fELF\x02\x01\x01\x00no-magic-here"), 0o755); err != nil { + t.Fatal(err) + } + if IsAppImage(plain) { + t.Error("ELF biasa bukan AppImage") + } +} + +// fakeAppImage membuat berkas dengan magic ELF + AI (cukup untuk verifikasi). +func fakeAppImage(size int) []byte { + data := make([]byte, size) + copy(data, []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}) + copy(data[8:], []byte{'A', 'I', 0x02}) + return data +}