diff --git a/.gitignore b/.gitignore index 7d7aa73..7f6d701 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Binaries /ftm /ftm-* +*.old *.exe bin/ diff --git a/cmd/ftm/main.go b/cmd/ftm/main.go index 6139e5d..bc167ee 100644 --- a/cmd/ftm/main.go +++ b/cmd/ftm/main.go @@ -4,44 +4,28 @@ import ( "flag" "fmt" "os" - "os/exec" - "path/filepath" "github.com/sthbryan/ftm/internal/app" + "github.com/sthbryan/ftm/internal/cli" "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/version" ) var BuildVersion string -func doUninstall() { - binaryPath, err := exec.LookPath("ftm") - if err != nil { - fmt.Println(i18n.T("uninstall_not_found")) - os.Exit(1) - } - - absPath, err := filepath.EvalSymlinks(binaryPath) - if err != nil { - absPath = binaryPath - } - - fmt.Println(i18n.TF("uninstall_removing", absPath)) - if err := os.Remove(absPath); err != nil { - fmt.Fprintf(os.Stderr, i18n.TF("uninstall_error", err.Error())+"\n") +func main() { + if err := cli.Init(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - - fmt.Println(i18n.T("uninstall_success")) -} - -func main() { var ( webOnly = flag.Bool("web", false, "Start web dashboard and open browser") server = flag.Bool("server", false, "Start web dashboard only (no browser)") port = flag.Int("port", 0, "Web server port (auto-detect if not specified)") showVersion = flag.Bool("version", false, "Show version") uninstall = flag.Bool("uninstall", false, "Uninstall ftm") + update = flag.Bool("update", false, "Update ftm to the latest release") + checkOnly = flag.Bool("check", false, "Check for updates without installing") ) flag.Parse() @@ -51,7 +35,26 @@ func main() { } if *uninstall { - doUninstall() + if err := cli.Uninstall(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + os.Exit(0) + } + + if *checkOnly { + if err := cli.Update(true); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + os.Exit(0) + } + + if *update { + if err := cli.Update(false); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } os.Exit(0) } diff --git a/internal/app/keyhandlers_list.go b/internal/app/keyhandlers_list.go index 1ecff68..0ad928c 100644 --- a/internal/app/keyhandlers_list.go +++ b/internal/app/keyhandlers_list.go @@ -47,6 +47,9 @@ func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.Keys.Delete): return m.handleListDelete() + + case key.Matches(msg, m.Keys.Update): + return m.handleListUpdate() } return m, nil @@ -158,6 +161,14 @@ func (m *Model) openConfigDir() { m.showMessage(i18n.T("config_opened")) } +func (m *Model) handleListUpdate() (tea.Model, tea.Cmd) { + if m.UpdateAvailable == nil { + return m, nil + } + m.showMessage(i18n.T("update_applying")) + return m, applyUpdateCmd(m.UpdateAvailable) +} + func (m *Model) playBeep() { if m.App.Config.NotificationSound { fmt.Fprint(os.Stdout, Bell) diff --git a/internal/app/model.go b/internal/app/model.go index c37da49..26425f4 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -22,6 +22,7 @@ func (m *Model) Init() tea.Cmd { return tea.Batch( tickCmd(), m.checkDownloadProgress(), + checkUpdateCmd(), ) } diff --git a/internal/app/model_types.go b/internal/app/model_types.go index ef6bd2a..ef589a1 100644 --- a/internal/app/model_types.go +++ b/internal/app/model_types.go @@ -13,6 +13,7 @@ import ( "github.com/sthbryan/ftm/internal/config" "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/providers" + "github.com/sthbryan/ftm/internal/updater" ) type viewState int @@ -53,6 +54,7 @@ type KeyMap struct { Back key.Binding Quit key.Binding Help key.Binding + Update key.Binding } var DefaultKeys = KeyMap{ @@ -125,6 +127,10 @@ var DefaultKeys = KeyMap{ key.WithKeys("?"), key.WithHelp("?", "help"), ), + Update: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "update"), + ), } type Model struct { @@ -148,6 +154,7 @@ type Model struct { PendingTunnel *config.TunnelConfig ProgressBar progress.Model SettingsView *views.SettingsView + UpdateAvailable *updater.Info } type FormData struct { diff --git a/internal/app/ui/views/list.go b/internal/app/ui/views/list.go index c717746..dae5d05 100644 --- a/internal/app/ui/views/list.go +++ b/internal/app/ui/views/list.go @@ -30,6 +30,7 @@ type ListView struct { Dashboard string Sessions int TwoColumnLimit int + UpdateBadge string } func NewListView() *ListView { @@ -69,7 +70,14 @@ func (l *ListView) twoColumn() string { b.WriteString(title) b.WriteString(strings.Repeat(" ", l.Width-lipgloss.Width(title)-lipgloss.Width(versionStr)-ui.HeaderMargin)) b.WriteString(versionStr) - b.WriteString("\n\n") + b.WriteString("\n") + + if l.UpdateBadge != "" { + badgeStyle := lipgloss.NewStyle().Foreground(ui.ThemeDefault.Gold).Bold(true) + b.WriteString(badgeStyle.Render(l.UpdateBadge)) + b.WriteString("\n") + } + b.WriteString("\n") leftWidth := int(float64(l.Width) * 0.4) rightWidth := l.Width - leftWidth - 3 @@ -152,7 +160,14 @@ func (l *ListView) singleColumn() string { b.WriteString(title) b.WriteString(strings.Repeat(" ", l.Width-lipgloss.Width(title)-lipgloss.Width(versionStr)-ui.HeaderMargin)) b.WriteString(versionStr) - b.WriteString("\n\n") + b.WriteString("\n") + + if l.UpdateBadge != "" { + badgeStyle := lipgloss.NewStyle().Foreground(ui.ThemeDefault.Gold).Bold(true) + b.WriteString(badgeStyle.Render(l.UpdateBadge)) + b.WriteString("\n") + } + b.WriteString("\n") if l.Dashboard != "" { urlStyle := lipgloss.NewStyle().Foreground(gold) diff --git a/internal/app/update.go b/internal/app/update.go index 1d7aef9..8808904 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -1,6 +1,9 @@ package app import ( + "os" + "time" + tea "github.com/charmbracelet/bubbletea" "github.com/sthbryan/ftm/internal/i18n" @@ -30,6 +33,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case statusUpdateMsg: return m.handleStatusUpdate(msg) + + case updateCheckMsg: + return m.handleUpdateCheck(msg) + + case updateApplyMsg: + return m.handleUpdateApply(msg) } return m, nil @@ -74,3 +83,25 @@ func (m *Model) handleStatusUpdate(msg statusUpdateMsg) (tea.Model, tea.Cmd) { } return m, nil } + +func (m *Model) handleUpdateCheck(msg updateCheckMsg) (tea.Model, tea.Cmd) { + if msg.err == nil && msg.info != nil && msg.info.HasUpdate { + m.UpdateAvailable = msg.info + } + return m, scheduleUpdateRecheck() +} + +func (m *Model) handleUpdateApply(msg updateApplyMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.showMessage(i18n.TF("update_apply_failed", msg.err.Error())) + return m, nil + } + os.Exit(0) + return m, nil +} + +func scheduleUpdateRecheck() tea.Cmd { + return tea.Tick(6*time.Hour, func(time.Time) tea.Msg { + return checkUpdateCmd()() + }) +} diff --git a/internal/app/update_check.go b/internal/app/update_check.go new file mode 100644 index 0000000..f8f3483 --- /dev/null +++ b/internal/app/update_check.go @@ -0,0 +1,33 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/sthbryan/ftm/internal/updater" + "github.com/sthbryan/ftm/internal/version" +) + +const updateRepo = "sthbryan/ftm" + +type updateCheckMsg struct { + info *updater.Info + err error +} + +type updateApplyMsg struct { + err error +} + +func checkUpdateCmd() tea.Cmd { + return func() tea.Msg { + info, err := updater.New(updateRepo).Check(version.Version) + return updateCheckMsg{info: info, err: err} + } +} + +func applyUpdateCmd(info *updater.Info) tea.Cmd { + return func() tea.Msg { + err := updater.New(updateRepo).Apply(info) + return updateApplyMsg{err: err} + } +} diff --git a/internal/app/view.go b/internal/app/view.go index 1448fa4..1e71837 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -43,6 +43,9 @@ func (m *Model) viewList() string { view.Dashboard = m.App.WebServer.URL() view.Sessions = m.App.WebServer.ClientCount() view.TwoColumnLimit = TwoColumnThreshold + if m.UpdateAvailable != nil { + view.UpdateBadge = i18n.TF("update_tui_badge", m.UpdateAvailable.LatestVersion) + } return view.Render() } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..1455a6a --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,7 @@ +package cli + +import "github.com/sthbryan/ftm/internal/i18n" + +func Init() error { + return i18n.Load() +} diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go new file mode 100644 index 0000000..be924dc --- /dev/null +++ b/internal/cli/uninstall.go @@ -0,0 +1,30 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/sthbryan/ftm/internal/i18n" +) + +func Uninstall() error { + binaryPath, err := exec.LookPath("ftm") + if err != nil { + return fmt.Errorf("%s", i18n.T("uninstall_not_found")) + } + + absPath, err := filepath.EvalSymlinks(binaryPath) + if err != nil { + absPath = binaryPath + } + + fmt.Println(i18n.TF("uninstall_removing", absPath)) + if err := os.Remove(absPath); err != nil { + return fmt.Errorf("%s", i18n.TF("uninstall_error", err.Error())) + } + + fmt.Println(i18n.T("uninstall_success")) + return nil +} diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..5d9dae9 --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,40 @@ +package cli + +import ( + "fmt" + + "github.com/sthbryan/ftm/internal/i18n" + "github.com/sthbryan/ftm/internal/updater" + "github.com/sthbryan/ftm/internal/version" +) + +const defaultRepo = "sthbryan/ftm" + +func Update(checkOnly bool) error { + u := updater.New(defaultRepo) + info, err := u.Check(version.Version) + if err != nil { + return fmt.Errorf("%s", i18n.TF("update_check_failed", err.Error())) + } + + fmt.Println(i18n.TF("update_current_version", info.CurrentVersion)) + fmt.Println(i18n.TF("update_latest_version", info.LatestVersion)) + + if !info.HasUpdate { + fmt.Println(i18n.T("update_up_to_date")) + return nil + } + + fmt.Println(i18n.TF("update_available", info.Tag)) + fmt.Println(i18n.TF("update_release_url", info.ReleaseURL)) + if checkOnly { + return nil + } + + fmt.Println(i18n.TF("update_downloading", info.AssetName)) + if err := u.Apply(info); err != nil { + return fmt.Errorf("%s", i18n.TF("update_apply_failed", err.Error())) + } + fmt.Println(i18n.TF("update_success", info.LatestVersion)) + return nil +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index a5f02dc..6def7fd 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -254,4 +254,20 @@ uninstall_success: "✓ ftm has been uninstalled" version_output: "Foundry Tunnel Manager v{0}" dashboard_url: "🌐 Dashboard running at: {0}" press_ctrl_c: "\nPress Ctrl+C to stop" -tui_hint: "Press 'w' in the TUI to open the dashboard" \ No newline at end of file +tui_hint: "Press 'w' in the TUI to open the dashboard" + +# Update +update_check_failed: "Failed to check for updates: {0}" +update_current_version: "Current version: {0}" +update_latest_version: "Latest version: {0}" +update_up_to_date: "✓ You are already on the latest version" +update_available: "Update available: {0}" +update_release_url: "Release notes: {0}" +update_downloading: "Downloading {0}..." +update_apply_failed: "Update failed: {0}" +update_success: "✓ Updated to v{0}. Restart ftm to use the new version." +update_tui_badge: "↑ Update available: v{0} — press 'u' to install" +update_applying: "Downloading and installing update... ftm will restart." +update_web_banner: "Update available: v{latest}" +update_web_button: "Update now" +update_web_notes: "Release notes" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index ab919a1..133842a 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -254,4 +254,20 @@ uninstall_success: "✓ ftm ha sido desinstalado" version_output: "Foundry Tunnel Manager v{0}" dashboard_url: "🌐 Dashboard ejecutándose en: {0}" press_ctrl_c: "\nPresiona Ctrl+C para detener" -tui_hint: "Presiona 'w' en el TUI para abrir el dashboard" \ No newline at end of file +tui_hint: "Presiona 'w' en el TUI para abrir el dashboard" + +# Update +update_check_failed: "Error al buscar actualizaciones: {0}" +update_current_version: "Versión actual: {0}" +update_latest_version: "Última versión: {0}" +update_up_to_date: "✓ Ya tienes la última versión" +update_available: "Actualización disponible: {0}" +update_release_url: "Notas del release: {0}" +update_downloading: "Descargando {0}..." +update_apply_failed: "Error al actualizar: {0}" +update_success: "✓ Actualizado a v{0}. Reinicia ftm para usar la nueva versión." +update_tui_badge: "↑ Actualización disponible: v{0} — pulsa 'u' para instalar" +update_applying: "Descargando e instalando actualización... ftm se reiniciará." +update_web_banner: "Actualización disponible: v{latest}" +update_web_button: "Actualizar" +update_web_notes: "Notas del release" \ No newline at end of file diff --git a/internal/updater/apply_unix.go b/internal/updater/apply_unix.go new file mode 100644 index 0000000..093d19d --- /dev/null +++ b/internal/updater/apply_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package updater + +import ( + "os" + "os/exec" + "runtime" +) + +func applyUpdate(execPath, tmpPath string) error { + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return err + } + + oldPath := execPath + ".old" + _ = os.Remove(oldPath) + if err := os.Rename(execPath, oldPath); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, execPath); err != nil { + _ = os.Rename(oldPath, execPath) + os.Remove(tmpPath) + return err + } + + _ = os.Remove(oldPath) + + if runtime.GOOS == "darwin" { + _ = exec.Command("xattr", "-d", "com.apple.quarantine", execPath).Run() + } + return nil +} diff --git a/internal/updater/apply_windows.go b/internal/updater/apply_windows.go new file mode 100644 index 0000000..40b3802 --- /dev/null +++ b/internal/updater/apply_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package updater + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +func applyUpdate(execPath, tmpPath string) error { + exeName := filepath.Base(execPath) + exeDir := filepath.Dir(execPath) + newPath := filepath.Join(exeDir, exeName+".new") + + if err := os.Rename(tmpPath, newPath); err != nil { + return fmt.Errorf("stage new binary: %w", err) + } + + batPath := filepath.Join(os.TempDir(), "ftm-update.bat") + bat := fmt.Sprintf( + `@echo off +:loop +timeout /t 1 /nobreak >nul +move /y "%s" "%s" >nul 2>&1 +if errorlevel 1 goto loop +del "%%~f0" +`, newPath, execPath) + + if err := os.WriteFile(batPath, []byte(bat), 0644); err != nil { + return fmt.Errorf("write updater script: %w", err) + } + + cmd := exec.Command("cmd", "/c", "start", "", "/b", batPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("launch updater script: %w", err) + } + return nil +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..6c3dbee --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,237 @@ +package updater + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + githubAPI = "https://api.github.com" + defaultRepo = "sthbryan/ftm" + apiTimeout = 15 * time.Second + downloadTimeout = 5 * time.Minute + userAgent = "ftm-updater" +) + +type Release struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + HTMLURL string `json:"html_url"` + Assets []Asset `json:"assets"` +} + +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type Info struct { + CurrentVersion string + LatestVersion string + Tag string + AssetURL string + AssetName string + HasUpdate bool + ReleaseURL string +} + +type Updater struct { + repo string + cli *http.Client +} + +func New(repo string) *Updater { + if repo == "" { + repo = defaultRepo + } + return &Updater{ + repo: repo, + cli: &http.Client{Timeout: apiTimeout}, + } +} + +func (u *Updater) Check(currentVersion string) (*Info, error) { + rel, err := u.fetchLatest() + if err != nil { + return nil, err + } + + latestVersion := strings.TrimPrefix(rel.TagName, "v") + cur := strings.TrimPrefix(currentVersion, "v") + + assetName := platformAssetName() + assetURL := "" + for _, a := range rel.Assets { + if a.Name == assetName { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return nil, fmt.Errorf("no asset %q in release %s (available: %s)", + assetName, rel.TagName, listAssetNames(rel.Assets)) + } + + return &Info{ + CurrentVersion: cur, + LatestVersion: latestVersion, + Tag: rel.TagName, + AssetURL: assetURL, + AssetName: assetName, + HasUpdate: compareSemver(latestVersion, cur) > 0, + ReleaseURL: rel.HTMLURL, + }, nil +} + +func (u *Updater) fetchLatest() (*Release, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", githubAPI, u.repo) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", userAgent) + + resp, err := u.cli.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("github API status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decode failed: %w", err) + } + if rel.Prerelease || rel.Draft { + return nil, fmt.Errorf("latest release is prerelease/draft") + } + if rel.TagName == "" { + return nil, fmt.Errorf("release has no tag_name") + } + return &rel, nil +} + +func (u *Updater) Apply(info *Info) error { + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("locate binary: %w", err) + } + if resolved, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolved + } + + tmp, err := os.CreateTemp(filepath.Dir(execPath), "ftm-update-*.tmp") + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + tmpPath := tmp.Name() + + if err := downloadFile(info.AssetURL, tmp); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("download: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temp: %w", err) + } + + return applyUpdate(execPath, tmpPath) +} + +func downloadFile(url string, dst *os.File) error { + cli := &http.Client{Timeout: downloadTimeout} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/octet-stream") + + resp, err := cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + _, err = io.Copy(dst, resp.Body) + return err +} + +func platformAssetName() string { + return fmt.Sprintf("ftm-%s-%s", osAlias(), archAlias()) +} + +func osAlias() string { + switch runtime.GOOS { + case "darwin": + return "macos" + case "linux": + return "linux" + case "windows": + return "windows" + } + return runtime.GOOS +} + +func archAlias() string { + switch runtime.GOARCH { + case "amd64": + return "x64" + case "arm64": + return "arm64" + } + return runtime.GOARCH +} + +func compareSemver(a, b string) int { + pa := parseSemver(a) + pb := parseSemver(b) + for i := 0; i < 3; i++ { + if pa[i] < pb[i] { + return -1 + } + if pa[i] > pb[i] { + return 1 + } + } + return 0 +} + +func parseSemver(v string) [3]int { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 3) + var out [3]int + for i := 0; i < 3 && i < len(parts); i++ { + seg := strings.SplitN(parts[i], "-", 2)[0] + seg = strings.SplitN(seg, "+", 2)[0] + n, _ := strconv.Atoi(seg) + out[i] = n + } + return out +} + +func listAssetNames(assets []Asset) string { + names := make([]string, 0, len(assets)) + for _, a := range assets { + names = append(names, a.Name) + } + return strings.Join(names, ", ") +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 00e5b57..a8e8314 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -51,6 +51,8 @@ func (h *Handlers) Route(w http.ResponseWriter, r *http.Request) { h.handleI18n(w, r) case path == "/api/i18n/current": h.handleI18nCurrent(w, r) + case path == "/api/update": + h.handleUpdate(w, r) case path == "/api/detect-port": h.handleDetectPort(w) case strings.HasPrefix(path, "/api/tunnels/"): diff --git a/internal/web/handlers_update.go b/internal/web/handlers_update.go new file mode 100644 index 0000000..ead7abb --- /dev/null +++ b/internal/web/handlers_update.go @@ -0,0 +1,59 @@ +package web + +import ( + "encoding/json" + "net/http" + "os" + "time" + + "github.com/sthbryan/ftm/internal/version" +) + +func (h *Handlers) handleUpdate(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.getUpdate(w) + case http.MethodPost: + h.postUpdate(w) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handlers) getUpdate(w http.ResponseWriter) { + resp := map[string]interface{}{ + "current": version.Version, + "latest": "", + "tag": "", + "assetName": "", + "releaseUrl": "", + "hasUpdate": false, + } + if info := h.server.updateSvc.Info(); info != nil { + resp["latest"] = info.LatestVersion + resp["tag"] = info.Tag + resp["assetName"] = info.AssetName + resp["releaseUrl"] = info.ReleaseURL + resp["hasUpdate"] = info.HasUpdate + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *Handlers) postUpdate(w http.ResponseWriter) { + info := h.server.updateSvc.Info() + if info == nil || !info.HasUpdate { + http.Error(w, "no update available", http.StatusBadRequest) + return + } + if err := h.server.updateSvc.Apply(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + go func() { + time.Sleep(500 * time.Millisecond) + os.Exit(0) + }() +} diff --git a/internal/web/server.go b/internal/web/server.go index 7870757..6f00375 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -28,6 +28,9 @@ type Server struct { clientsMu sync.RWMutex handlers *Handlers StatusChannel chan config.TunnelStatus + updateSvc *updateService + updateCtx context.Context + updateCancel context.CancelFunc } func NewServer(manager *process.Manager, cfg *config.Config) *Server { @@ -38,6 +41,8 @@ func NewServer(manager *process.Manager, cfg *config.Config) *Server { StatusChannel: make(chan config.TunnelStatus, 64), } s.handlers = NewHandlers(manager, cfg, s) + s.updateCtx, s.updateCancel = context.WithCancel(context.Background()) + s.updateSvc = newUpdateService(s.broadcast) return s } @@ -71,6 +76,7 @@ func (s *Server) Start() error { Handler: mux, } + s.updateSvc.Start(s.updateCtx) go s.installProgressLoop() go s.statusUpdateLoop() go s.httpServer.ListenAndServe() @@ -108,6 +114,9 @@ func (s *Server) setupRoutes() *http.ServeMux { } func (s *Server) Stop() error { + if s.updateCancel != nil { + s.updateCancel() + } if s.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*5e9) defer cancel() diff --git a/internal/web/update_service.go b/internal/web/update_service.go new file mode 100644 index 0000000..bfb7a24 --- /dev/null +++ b/internal/web/update_service.go @@ -0,0 +1,98 @@ +package web + +import ( + "context" + "log" + "sync" + "time" + + "github.com/sthbryan/ftm/internal/updater" + "github.com/sthbryan/ftm/internal/version" +) + +const ( + updateRepo = "sthbryan/ftm" + updateCheckInterval = 6 * time.Hour +) + +type updateService struct { + mu sync.RWMutex + info *updater.Info + broadcast func(string) + repo string + current string +} + +func newUpdateService(broadcast func(string)) *updateService { + return &updateService{ + broadcast: broadcast, + repo: updateRepo, + current: version.Version, + } +} + +func (s *updateService) Start(ctx context.Context) { + if err := s.Check(); err != nil { + log.Printf("update check failed: %v", err) + } + go s.loop(ctx) +} + +func (s *updateService) loop(ctx context.Context) { + t := time.NewTicker(updateCheckInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.Check(); err != nil { + log.Printf("update check failed: %v", err) + } + } + } +} + +func (s *updateService) Check() error { + info, err := updater.New(s.repo).Check(s.current) + if err != nil { + return err + } + s.mu.Lock() + hadUpdate := s.info != nil && s.info.HasUpdate + s.info = info + s.mu.Unlock() + if info.HasUpdate && !hadUpdate { + s.broadcastUpdate(info) + } + return nil +} + +func (s *updateService) Info() *updater.Info { + s.mu.RLock() + defer s.mu.RUnlock() + return s.info +} + +func (s *updateService) Apply() error { + s.mu.RLock() + info := s.info + s.mu.RUnlock() + if info == nil || !info.HasUpdate { + return nil + } + return updater.New(s.repo).Apply(info) +} + +func (s *updateService) broadcastUpdate(info *updater.Info) { + payload := map[string]interface{}{ + "type": "update_available", + "current": s.current, + "latest": info.LatestVersion, + "tag": info.Tag, + "assetName": info.AssetName, + "releaseUrl": info.ReleaseURL, + } + data, _ := MarshalJSON(payload) + s.broadcast(string(data)) +} diff --git a/web-svelte/src/lib/api/endpoints/index.ts b/web-svelte/src/lib/api/endpoints/index.ts index 1d6456d..5cec5bf 100644 --- a/web-svelte/src/lib/api/endpoints/index.ts +++ b/web-svelte/src/lib/api/endpoints/index.ts @@ -3,4 +3,6 @@ export { providersApi } from './providers'; export { logsApi } from './logs'; export { statusApi } from './status'; export { settingsApi } from './settings'; +export { updateApi } from './update'; export type { Settings } from './settings'; +export type { UpdateInfo } from './update'; diff --git a/web-svelte/src/lib/api/endpoints/update.ts b/web-svelte/src/lib/api/endpoints/update.ts new file mode 100644 index 0000000..71a8bf9 --- /dev/null +++ b/web-svelte/src/lib/api/endpoints/update.ts @@ -0,0 +1,15 @@ +import { api } from '../client'; + +export interface UpdateInfo { + current: string; + latest: string; + tag: string; + assetName: string; + releaseUrl: string; + hasUpdate: boolean; +} + +export const updateApi = { + get: (): Promise => api.get('update').json(), + apply: (): Promise<{ ok: boolean }> => api.post('update', { json: {} }).json(), +}; diff --git a/web-svelte/src/lib/components/UpdateBanner.svelte b/web-svelte/src/lib/components/UpdateBanner.svelte new file mode 100644 index 0000000..7a86bb7 --- /dev/null +++ b/web-svelte/src/lib/components/UpdateBanner.svelte @@ -0,0 +1,69 @@ + + +{#if update.info?.hasUpdate} +
+
+ + ↑ {t('update_web_banner', { latest: update.info.latest })} + + + {t('update_web_notes')} + +
+ +
+ {#if update.applying} + {t('update_applying')} + {:else} + + {/if} +
+
+{/if} + +{#if update.error} +
+ {t('update_apply_failed', { 0: update.error })} +
+{/if} diff --git a/web-svelte/src/lib/stores/update.svelte.ts b/web-svelte/src/lib/stores/update.svelte.ts new file mode 100644 index 0000000..bdeef7d --- /dev/null +++ b/web-svelte/src/lib/stores/update.svelte.ts @@ -0,0 +1,37 @@ +import { updateApi, type UpdateInfo } from '$lib/api'; + +let info: UpdateInfo | null = $state(null); +let applying = $state(false); +let error: string | null = $state(null); + +export function useUpdate() { + return { + get info() { return info; }, + get applying() { return applying; }, + get error() { return error; }, + + async check() { + try { + info = await updateApi.get(); + error = null; + } catch { + // silent: no update info or offline + } + }, + + async apply() { + applying = true; + error = null; + try { + await updateApi.apply(); + } catch (e) { + applying = false; + error = e instanceof Error ? e.message : String(e); + } + }, + + set(next: UpdateInfo) { + info = next; + }, + }; +} diff --git a/web-svelte/src/routes/+page.svelte b/web-svelte/src/routes/+page.svelte index bd0daa4..98aa671 100644 --- a/web-svelte/src/routes/+page.svelte +++ b/web-svelte/src/routes/+page.svelte @@ -5,6 +5,7 @@ import { useProviders } from "$lib/stores/providers.svelte"; import { useTheme } from "$lib/stores/theme.svelte"; import Header from "$lib/components/Header.svelte"; + import UpdateBanner from "$lib/components/UpdateBanner.svelte"; import DeleteModal from "$lib/components/DeleteModal.svelte"; import NotificationPermission from "$lib/components/NotificationPermission.svelte"; import ConnectionsPanel from "$lib/components/ConnectionsPanel.svelte"; @@ -94,6 +95,8 @@
+ +