Skip to content

Commit 70a7e30

Browse files
feat: add in-app update system with about dialog
1 parent 552f1e3 commit 70a7e30

10 files changed

Lines changed: 1037 additions & 5 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ jobs:
171171
name: mirroid-appimage
172172
path: Mirroid-*.AppImage
173173

174+
- name: Copy raw binary (Linux)
175+
if: matrix.target == 'linux'
176+
run: cp _tar/mirroid/mirroid mirroid-linux-amd64
177+
178+
- name: Upload raw binary (Linux)
179+
if: matrix.target == 'linux'
180+
uses: actions/upload-artifact@v6
181+
with:
182+
name: mirroid-linux-amd64
183+
path: mirroid-linux-amd64
184+
174185
- name: Inject deps into app bundle (macOS)
175186
if: matrix.target == 'darwin'
176187
shell: bash
@@ -206,6 +217,17 @@ jobs:
206217
name: mirroid-macos-arm64.dmg
207218
path: mirroid-macos-arm64.dmg
208219

220+
- name: Copy raw binary (macOS)
221+
if: matrix.target == 'darwin'
222+
run: cp Mirroid.app/Contents/MacOS/Mirroid mirroid-macos-arm64
223+
224+
- name: Upload raw binary (macOS)
225+
if: matrix.target == 'darwin'
226+
uses: actions/upload-artifact@v6
227+
with:
228+
name: mirroid-macos-arm64
229+
path: mirroid-macos-arm64
230+
209231
release:
210232
needs: build
211233
runs-on: ubuntu-latest

internal/config/config.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ type Config struct {
2121

2222
// AppConfig holds top-level app settings.
2323
type AppConfig struct {
24-
ScrcpyPath string `json:"scrcpy_path"`
25-
ADBPath string `json:"adb_path"`
24+
ScrcpyPath string `json:"scrcpy_path"`
25+
ADBPath string `json:"adb_path"`
26+
AutoCheckUpdates bool `json:"auto_check_updates"`
27+
LastUpdateCheck int64 `json:"last_update_check"`
2628
}
2729

2830
// New creates a Config, ensuring the config directory exists.
@@ -40,8 +42,9 @@ func New() (*Config, error) {
4042
c := &Config{
4143
dir: dir,
4244
AppConf: AppConfig{
43-
ScrcpyPath: "scrcpy",
44-
ADBPath: "adb",
45+
ScrcpyPath: "scrcpy",
46+
ADBPath: "adb",
47+
AutoCheckUpdates: true,
4548
},
4649
}
4750

internal/ui/about.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package ui
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/url"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
"fyne.io/fyne/v2"
12+
"fyne.io/fyne/v2/canvas"
13+
"fyne.io/fyne/v2/container"
14+
"fyne.io/fyne/v2/dialog"
15+
"fyne.io/fyne/v2/theme"
16+
"fyne.io/fyne/v2/widget"
17+
18+
"mirroid/internal/updater"
19+
)
20+
21+
// showAboutDialog shows a modal About dialog with app info.
22+
func (a *App) showAboutDialog() {
23+
icon := canvas.NewImageFromResource(theme.ComputerIcon())
24+
icon.FillMode = canvas.ImageFillContain
25+
icon.SetMinSize(fyne.NewSize(64, 64))
26+
27+
version := a.fyneApp.Metadata().Version
28+
if version == "" {
29+
version = "dev"
30+
}
31+
32+
title := canvas.NewText("Mirroid", theme.Color(theme.ColorNameForeground))
33+
title.TextSize = 22
34+
title.TextStyle = fyne.TextStyle{Bold: true}
35+
title.Alignment = fyne.TextAlignCenter
36+
37+
versionLabel := widget.NewLabelWithStyle(
38+
"Version "+version,
39+
fyne.TextAlignCenter, fyne.TextStyle{Italic: true},
40+
)
41+
42+
description := widget.NewLabelWithStyle(
43+
"A desktop GUI for scrcpy",
44+
fyne.TextAlignCenter, fyne.TextStyle{},
45+
)
46+
47+
ghURL, _ := url.Parse("https://github.com/EverythingSuckz/Mirroid")
48+
githubLink := widget.NewHyperlink("GitHub", ghURL)
49+
50+
content := container.NewVBox(
51+
container.NewCenter(icon),
52+
container.NewCenter(title),
53+
container.NewCenter(versionLabel),
54+
container.NewCenter(description),
55+
widget.NewSeparator(),
56+
container.NewCenter(githubLink),
57+
)
58+
59+
dlg := dialog.NewCustom("About Mirroid", "Close", content, a.window)
60+
dlg.Resize(fyne.NewSize(350, 320))
61+
dlg.Show()
62+
}
63+
64+
// checkForUpdates checks for available updates.
65+
// If silent is true (startup auto-check), no UI is shown unless an update is available.
66+
func (a *App) checkForUpdates(silent bool) {
67+
version := a.fyneApp.Metadata().Version
68+
if version == "" {
69+
version = "0.0.0"
70+
}
71+
72+
u := updater.New(version)
73+
74+
if !silent {
75+
// Show "Checking..." dialog for manual checks
76+
checking := dialog.NewCustomWithoutButtons("Checking for Updates",
77+
container.NewVBox(
78+
widget.NewLabel("Checking for updates..."),
79+
widget.NewProgressBarInfinite(),
80+
), a.window)
81+
checking.Resize(fyne.NewSize(300, 120))
82+
checking.Show()
83+
84+
go func() {
85+
result, err := u.CheckForUpdate()
86+
// Save timestamp only on successful check
87+
if err == nil {
88+
a.cfg.AppConf.LastUpdateCheck = time.Now().Unix()
89+
_ = a.cfg.SaveAppConfig()
90+
}
91+
fyne.Do(func() {
92+
checking.Hide()
93+
if err != nil {
94+
slog.Error("update check failed", "error", err)
95+
dialog.ShowError(fmt.Errorf("Could not check for updates:\n%s", err), a.window)
96+
return
97+
}
98+
if result.Available {
99+
a.showUpdateDialog(u, result)
100+
} else {
101+
dialog.ShowInformation("Up to Date",
102+
fmt.Sprintf("You're running the latest version (v%s).", result.CurrentVersion),
103+
a.window)
104+
}
105+
})
106+
}()
107+
return
108+
}
109+
110+
// Silent mode: no UI unless update is available
111+
go func() {
112+
result, err := u.CheckForUpdate()
113+
if err != nil {
114+
slog.Debug("silent update check failed", "error", err)
115+
return
116+
}
117+
// Save timestamp only on successful check
118+
a.cfg.AppConf.LastUpdateCheck = time.Now().Unix()
119+
_ = a.cfg.SaveAppConfig()
120+
if result.Available {
121+
fyne.Do(func() {
122+
a.showUpdateDialog(u, result)
123+
})
124+
}
125+
}()
126+
}
127+
128+
// showUpdateDialog shows the update-available dialog with changelog and install button.
129+
func (a *App) showUpdateDialog(u *updater.Updater, result *updater.UpdateResult) {
130+
versionLabel := widget.NewLabelWithStyle(
131+
fmt.Sprintf("v%s → v%s", result.CurrentVersion, result.LatestVersion),
132+
fyne.TextAlignCenter, fyne.TextStyle{Bold: true},
133+
)
134+
135+
// Changelog
136+
var changelogWidget fyne.CanvasObject
137+
if result.Release.Body != "" {
138+
rt := widget.NewRichTextFromMarkdown(result.Release.Body)
139+
rt.Wrapping = fyne.TextWrapWord
140+
changelogWidget = container.NewVScroll(rt)
141+
changelogWidget.(*container.Scroll).SetMinSize(fyne.NewSize(450, 200))
142+
} else {
143+
changelogWidget = widget.NewLabel("No changelog available.")
144+
}
145+
146+
installType := updater.DetectInstallType()
147+
148+
var dlg dialog.Dialog
149+
var actionBtn *widget.Button
150+
151+
if installType == updater.InstallSystem {
152+
// .deb users: open browser to release page
153+
actionBtn = widget.NewButton("View on GitHub", func() {
154+
ghURL, _ := url.Parse(result.Release.HTMLURL)
155+
_ = a.fyneApp.OpenURL(ghURL)
156+
dlg.Hide()
157+
})
158+
} else {
159+
actionBtn = widget.NewButton("Install Update", func() {
160+
dlg.Hide()
161+
a.performUpdate(u, result)
162+
})
163+
}
164+
actionBtn.Importance = widget.HighImportance
165+
166+
laterBtn := widget.NewButton("Later", func() {
167+
dlg.Hide()
168+
})
169+
170+
buttons := container.NewHBox(laterBtn, actionBtn)
171+
172+
content := container.NewVBox(
173+
container.NewCenter(versionLabel),
174+
widget.NewSeparator(),
175+
changelogWidget,
176+
widget.NewSeparator(),
177+
container.NewCenter(buttons),
178+
)
179+
180+
dlg = dialog.NewCustomWithoutButtons("Update Available", content, a.window)
181+
dlg.Resize(fyne.NewSize(500, 400))
182+
dlg.Show()
183+
}
184+
185+
// performUpdate downloads and applies the update with a progress dialog.
186+
func (a *App) performUpdate(u *updater.Updater, result *updater.UpdateResult) {
187+
installType := updater.DetectInstallType()
188+
189+
// Find the right asset
190+
assetName := updater.AssetName(installType)
191+
var asset *updater.Asset
192+
if assetName != "" {
193+
asset = updater.FindAsset(result.Release.Assets, assetName)
194+
} else if installType == updater.InstallAppImage {
195+
asset = updater.FindAssetBySuffix(result.Release.Assets, ".AppImage")
196+
}
197+
198+
if asset == nil {
199+
dialog.ShowError(fmt.Errorf("Could not find a compatible update for your platform."), a.window)
200+
return
201+
}
202+
203+
// Progress UI
204+
progressBar := widget.NewProgressBar()
205+
statusLabel := widget.NewLabel("Downloading update...")
206+
207+
progressContent := container.NewVBox(
208+
statusLabel,
209+
progressBar,
210+
)
211+
212+
progressDlg := dialog.NewCustomWithoutButtons("Updating Mirroid", progressContent, a.window)
213+
progressDlg.Resize(fyne.NewSize(400, 130))
214+
progressDlg.Show()
215+
216+
go func() {
217+
exe, err := os.Executable()
218+
if err != nil {
219+
fyne.Do(func() {
220+
progressDlg.Hide()
221+
dialog.ShowError(fmt.Errorf("Could not determine executable path: %s", err), a.window)
222+
})
223+
return
224+
}
225+
exe, _ = filepath.EvalSymlinks(exe)
226+
227+
// Choose download directory:
228+
// - Installer installs (Program Files) → OS temp dir (no write access to install dir)
229+
// - Portable → exe directory (same filesystem for atomic rename)
230+
destDir := filepath.Dir(exe)
231+
if installType == updater.InstallInstaller {
232+
destDir = os.TempDir()
233+
}
234+
235+
// Download with throttled progress updates (at most every 150ms)
236+
var lastProgressUpdate time.Time
237+
tmpPath, err := u.Download(asset.BrowserDownloadURL, destDir, func(received, total int64) {
238+
if total <= 0 {
239+
return
240+
}
241+
now := time.Now()
242+
if received < total && now.Sub(lastProgressUpdate) < 150*time.Millisecond {
243+
return
244+
}
245+
lastProgressUpdate = now
246+
fyne.Do(func() {
247+
progressBar.SetValue(float64(received) / float64(total))
248+
statusLabel.SetText(fmt.Sprintf("Downloading... %.1f / %.1f MB",
249+
float64(received)/1024/1024, float64(total)/1024/1024))
250+
})
251+
})
252+
if err != nil {
253+
fyne.Do(func() {
254+
progressDlg.Hide()
255+
dialog.ShowError(fmt.Errorf("Download failed: %s", err), a.window)
256+
})
257+
return
258+
}
259+
260+
fyne.Do(func() {
261+
statusLabel.SetText("Applying update...")
262+
progressBar.SetValue(1.0)
263+
})
264+
265+
// Apply
266+
if err := updater.Apply(tmpPath, exe, installType); err != nil {
267+
os.Remove(tmpPath)
268+
fyne.Do(func() {
269+
progressDlg.Hide()
270+
dialog.ShowError(fmt.Errorf("Update failed: %s", err), a.window)
271+
})
272+
return
273+
}
274+
275+
fyne.Do(func() {
276+
statusLabel.SetText("Restarting...")
277+
})
278+
279+
// For installer type, the update script was launched; exit so the installer can replace files
280+
if installType == updater.InstallInstaller {
281+
os.Exit(0)
282+
}
283+
284+
// Restart
285+
if err := updater.Restart(exe); err != nil {
286+
fyne.Do(func() {
287+
progressDlg.Hide()
288+
dialog.ShowError(fmt.Errorf("Restart failed: %s\nPlease restart manually.", err), a.window)
289+
})
290+
}
291+
}()
292+
}
293+
294+
// shouldAutoCheck returns true if enough time has passed since the last update check.
295+
func (a *App) shouldAutoCheck() bool {
296+
last := a.cfg.AppConf.LastUpdateCheck
297+
if last == 0 {
298+
return true
299+
}
300+
const cooldown = 12 * 60 * 60 // 12 hours in seconds
301+
return time.Now().Unix()-last > cooldown
302+
}

0 commit comments

Comments
 (0)