Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ during the 0.4.0 release, so earlier detail lives in the git history and in the
change ledgers under [`docs/test/test-strategy.md`](docs/test/test-strategy.md)
and [`docs/requirements/system-requirements.md`](docs/requirements/system-requirements.md).

## [Unreleased]

### Added

- **Automatic update check.** The app now checks GitHub for a newer release
once at start-up and, if there is one, shows a clickable link in the top
status bar beside the connection indicator. On by default; turn it off under
Settings. It stays silent unless there is a real update, and makes at most
one request per launch.

## [0.8.0] — 2026-07-25

### Added
Expand Down
94 changes: 81 additions & 13 deletions app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,9 @@ struct App {
ptt_toggle: bool,
// Mode-adaptive UI: per-mode control emphasis (docs/concept/mode-aware-ui.md).
mode_aware_ui: bool,
/// Automatically check for a newer release at start-up, opt-out preference
/// (FR-UI-UPD-02). Mirrors `Prefs::auto_update_check`.
auto_update_check: bool,
// Elecraft K-Pod USB control surface enabled (runtime opt-in, FR-KPOD-04).
kpod_enabled: bool,
// K-Pod function-switch macro table: 16 slots (F1–F8 × tap/hold), each a CAT
Expand Down Expand Up @@ -632,6 +635,8 @@ enum Message {
ToggleArm,
TogglePttMode,
ToggleModeAwareUi,
/// Auto update check on/off (FR-UI-UPD-02).
ToggleAutoUpdate,
ToggleKpod,
/// Run an assigned macro from the on-screen MACROS tab (FR-MACRO-01): send
/// its CAT string, gated by the TX arm exactly as the K-Pod press is.
Expand Down Expand Up @@ -873,6 +878,7 @@ impl App {
let ptt_hotkey = prefs.ptt_hotkey.clone();
let ptt_toggle = prefs.ptt_toggle;
let mode_aware_ui = prefs.mode_aware_ui;
let auto_update_check = prefs.auto_update_check;
let diag_enabled = prefs.diagnostics_window;
let tooltips = prefs.tooltips;

Expand Down Expand Up @@ -1037,6 +1043,7 @@ impl App {
ptt_hotkey,
ptt_toggle,
mode_aware_ui,
auto_update_check,
kpod_enabled,
kpod_buttons,
capturing_hotkey: false,
Expand All @@ -1060,6 +1067,12 @@ impl App {
loaded_commands: Vec::new(),
backup_status: String::new(),
};
// One silent update check per launch when opted in (FR-UI-UPD-02). It
// reuses the operator-initiated path; only a found update surfaces
// anything, in the header status area.
if app.auto_update_check {
window_tasks.push(update_check_task());
}
(app, Task::batch(window_tasks))
}

Expand Down Expand Up @@ -1339,6 +1352,7 @@ impl App {
ptt_hotkey: self.ptt_hotkey.clone(),
ptt_toggle: self.ptt_toggle,
mode_aware_ui: self.mode_aware_ui,
auto_update_check: self.auto_update_check,
kpod_enabled: self.kpod_enabled,
kpod_buttons: self.kpod_buttons.clone(),
..Default::default()
Expand Down Expand Up @@ -2025,6 +2039,10 @@ impl App {
self.mode_aware_ui = !self.mode_aware_ui;
self.save_config();
}
Message::ToggleAutoUpdate => {
self.auto_update_check = !self.auto_update_check;
self.save_config();
}
Message::ToggleKpod => {
self.kpod_enabled = !self.kpod_enabled;
self.send(WorkerCmd::SetKpodEnabled(self.kpod_enabled));
Expand Down Expand Up @@ -2251,20 +2269,11 @@ impl App {
Message::OpenUrl(url) => open_url(url),
Message::OpenUrlOwned(url) => open_url(&url),
Message::CheckUpdate => {
// Operator-initiated only; never on a timer. The request is
// blocking, so it runs off the UI thread (FR-UI-UPD-01).
// The blocking request runs off the UI thread. The About box
// shows "Checking…"; the start-up auto-check uses the same task
// without setting that visible state (FR-UI-UPD-01/02).
self.update_status = update::UpdateStatus::Checking;
let current = ui::app_version().to_string();
return Task::perform(
async move {
tokio::task::spawn_blocking(move || update::check_now(&current))
.await
.unwrap_or_else(|e| {
update::UpdateStatus::Failed(format!("check did not run: {e}"))
})
},
Message::UpdateChecked,
);
return update_check_task();
}
Message::UpdateChecked(status) => self.update_status = status,
Message::AfRecord => self.send(WorkerCmd::AfRecord),
Expand Down Expand Up @@ -6120,11 +6129,39 @@ impl App {
// is a bad trade, so this stays as it is until the status message has
// somewhere else to go.
let status_strip = Text::new(status_bits.join(" · ")).size(12).color(dim);
// Update notification (FR-UI-UPD-02): only a *found* update surfaces
// anything, and it lives here in the top status area beside the
// connection indicator, as a clickable link to the release page.
// Silent otherwise — checking, up-to-date and failed show nothing.
let update_note: Element<Message> = match &self.update_status {
update::UpdateStatus::Available { version, url } => {
let accent = role_color(ui::ColorRole::VfoA);
let url = url.clone();
Button::new(
Text::new(format!("● update {version}"))
.size(12)
.color(accent),
)
.style(move |_t: &Theme, status: button::Status| button::Style {
background: None,
text_color: match status {
button::Status::Hovered | button::Status::Pressed => Color::WHITE,
_ => accent,
},
..button::Style::default()
})
.padding(0)
.on_press(Message::OpenUrlOwned(url))
.into()
}
_ => Space::with_width(Length::Shrink).into(),
};
let header = Row::new()
.spacing(12)
.align_y(Alignment::Center)
.push(Text::new("K4 REMOTE").size(20))
.push(status_ind)
.push(update_note)
.push(
Text::new(self.ui.status.clone())
.size(12)
Expand Down Expand Up @@ -7519,6 +7556,22 @@ impl App {
.color(dim),
),
)
.push(
Row::new()
.spacing(8)
.align_y(Alignment::Center)
.push(small_btn_pair(
self.auto_update_check,
"Update check: ON",
"Update check: OFF",
Message::ToggleAutoUpdate,
))
.push(
Text::new("check GitHub for a newer release at start-up")
.size(10)
.color(dim),
),
)
.push(
Row::new()
.spacing(8)
Expand Down Expand Up @@ -8422,6 +8475,21 @@ fn meter_style(strong: bool) -> impl Fn(&Theme) -> progress_bar::Style {

/// Receiver badge: a filled tag in the VFO's semantic colour with dark text,
/// like the K4's corner `A`/`B` markers (FR-UI-10).
/// A background task that runs the blocking GitHub release check off the UI
/// thread and reports the result (FR-UI-UPD-01/02). Shared by the About-box
/// button and the start-up auto-check.
fn update_check_task() -> Task<Message> {
let current = ui::app_version().to_string();
Task::perform(
async move {
tokio::task::spawn_blocking(move || update::check_now(&current))
.await
.unwrap_or_else(|e| update::UpdateStatus::Failed(format!("check did not run: {e}")))
},
Message::UpdateChecked,
)
}

fn badge(label: &'static str, role: ui::ColorRole) -> Element<'static, Message> {
Container::new(Text::new(label).size(15).color(Color::BLACK))
.style(move |_theme: &Theme| container::Style {
Expand Down
6 changes: 6 additions & 0 deletions crates/k4-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ pub struct Prefs {
/// Use the mode-adaptive UI (per-mode control emphasis). Default on.
#[serde(default = "default_true")]
pub mode_aware_ui: bool,
/// Automatically check for a newer release at start-up (FR-UI-UPD-02).
/// Default on; opt-out here. One check per launch, silent unless an update
/// is found.
#[serde(default = "default_true")]
pub auto_update_check: bool,
/// Enable the Elecraft K-Pod USB control surface. Default off (opt-in); the
/// app runs normally whether or not a K-Pod is attached.
#[serde(default)]
Expand Down Expand Up @@ -360,6 +365,7 @@ impl Default for Prefs {
ptt_hotkey: default_ptt_hotkey(),
ptt_toggle: true,
mode_aware_ui: true,
auto_update_check: true,
kpod_enabled: false,
kpod_buttons: default_kpod_buttons(),
}
Expand Down
20 changes: 20 additions & 0 deletions crates/k4-config/tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,23 @@ fn fr_kpod_06_button_macros_seed_and_persist() {
"edited table must persist"
);
}

/// The automatic update check is opt-out: default on, and it survives a
/// save/load round-trip so an operator's choice sticks.
/// trace: FR-UI-UPD-02
#[test]
fn fr_ui_upd_02_auto_update_check_defaults_on_and_persists() {
assert!(
Prefs::default().auto_update_check,
"default opt-in: the check is on unless turned off"
);

// A round-trip through TOML preserves an explicit opt-out.
let prefs = Prefs {
auto_update_check: false,
..Default::default()
};
let toml = toml::to_string(&prefs).expect("serialize");
let back: Prefs = toml::from_str(&toml).expect("deserialize");
assert!(!back.auto_update_check, "opt-out is remembered");
}
Loading
Loading