Auto update: cek rilis GitHub, notifikasi, dan pemasangan AppImage
- Paket internal/update: ambil rilis terbaru (API GitHub), bandingkan versi semver sederhana, unduh AppImage ke berkas sementara lalu tukar atomik (verifikasi magic ELF+AI; kalau gagal, AppImage lama tetap utuh) - UI: cek otomatis 2 detik setelah aplikasi tampil (hanya kalau jalan sebagai AppImage), dialog notifikasi versi baru + catatan rilis, tombol Update now / Halaman rilis / Nanti - Pemasangan: lewat AppImageUpdate kalau terpasang, kalau tidak unduh sendiri; setelah selesai ditawarkan Restart (jalankan AppImage baru lalu tutup yang lama) - Tombol footer Cek Update memakai alur yang sama dan selalu memberi jawaban - LAZYRUN_UPDATE_API untuk mengarahkan cek update ke server lain (tes/fork) - Tes: perbandingan versi, parsing rilis, penukaran AppImage, penolakan berkas bukan AppImage
This commit is contained in:
1 parent
59145910b5
commit
e8fa35d70d
5 files changed
+591
-14
No files matched your search
@@ -39,6 +39,7 @@ func Run(a fyne.App, version string) {
|
|||||||
ui, loadErr := build(a, version)
|
ui, loadErr := build(a, version)
|
||||||
ui.win.Show()
|
ui.win.Show()
|
||||||
ui.flashCards()
|
ui.flashCards()
|
||||||
|
ui.scheduleUpdateCheck()
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
dialog.ShowError(loadErr, ui.win)
|
dialog.ShowError(loadErr, ui.win)
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-14
@@ -4,23 +4,169 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
"fyne.io/fyne/v2/dialog"
|
"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.
|
// checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan.
|
||||||
func (ui *app) checkUpdates() {
|
func (ui *app) checkUpdates() { ui.checkForUpdate(true) }
|
||||||
image := os.Getenv("APPIMAGE")
|
|
||||||
if image == "" {
|
// scheduleUpdateCheck memeriksa rilis terbaru beberapa saat setelah aplikasi tampil.
|
||||||
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("not_appimage"), ui.version), ui.win)
|
func (ui *app) scheduleUpdateCheck() {
|
||||||
return
|
if os.Getenv("APPIMAGE") == "" {
|
||||||
}
|
return // dijalankan dari source: jangan ganggu dengan cek update
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("<html>404</html>"))
|
||||||
|
}))
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user