Network-wide announcements signed by the bootstrap anchor posting id
New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.
Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
stores the latest-per-category/channel in the settings kv, keyed
e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
version > CARGO_PKG_VERSION on the user's selected channel, a banner
appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
button + current status line.
Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
`get_feed*` / `list_posts*` query sites updated to also exclude the
new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
`apply_announcement_if_applicable`, `latest_release`,
`build_announcement_post`, and a `StoredAnnouncement` envelope saved
to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
id equals the anchor constant — accidental use on client installs
fails loud.
Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
alongside Control and Profile. Same pattern; same guarantees.
CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
--ann-channel stable --ann-version X --ann-title ... --ann-body ...
--ann-url https://itsgoin.com/download.html` — builds + stores +
propagates the signed post, exits.
deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
(after binary swap, before start). The DB is free during that gap,
so the one-shot can write without conflicting with the running
daemon. The restarted daemon loads all storage on boot and serves
the new announcement to pulling peers.
Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
— returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
refuses non-http(s) URLs. Android needs the opener plugin — TODO.
Frontend:
- Upgrade banner on the welcome screen, populated by
`loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
+ current status line.
Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
This commit is contained in:
parent
67d9367eec
commit
481e1c8435
13 changed files with 728 additions and 15 deletions
File diff suppressed because one or more lines are too long
|
|
@ -241,6 +241,7 @@ async fn post_to_dto(
|
|||
VisibilityIntent::Control => "control".to_string(),
|
||||
VisibilityIntent::Profile => "profile".to_string(),
|
||||
VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(),
|
||||
VisibilityIntent::Announcement => "announcement".to_string(),
|
||||
},
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
|
|
@ -1339,6 +1340,117 @@ struct KnownAnchorDto {
|
|||
addresses: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleaseAnnouncementDto {
|
||||
channel: String,
|
||||
version: String,
|
||||
title: String,
|
||||
body: String,
|
||||
download_url: String,
|
||||
timestamp_ms: u64,
|
||||
is_newer_than_current: bool,
|
||||
}
|
||||
|
||||
/// Check for a newer release on the user's selected update channel.
|
||||
/// Returns None if no announcement is stored for that channel or the
|
||||
/// stored announcement isn't actually newer than the running build.
|
||||
#[tauri::command]
|
||||
async fn check_release_announcement(
|
||||
state: State<'_, AppNode>,
|
||||
channel: String,
|
||||
) -> Result<Option<ReleaseAnnouncementDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?;
|
||||
let Some(stored) = stored else { return Ok(None); };
|
||||
let Some(release) = stored.content.release.as_ref() else { return Ok(None); };
|
||||
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
let is_newer = version_is_newer(&release.version, current);
|
||||
if !is_newer { return Ok(None); }
|
||||
|
||||
Ok(Some(ReleaseAnnouncementDto {
|
||||
channel: release.channel.clone(),
|
||||
version: release.version.clone(),
|
||||
title: stored.content.title.clone(),
|
||||
body: stored.content.body.clone(),
|
||||
download_url: release.download_url.clone(),
|
||||
timestamp_ms: stored.content.timestamp_ms,
|
||||
is_newer_than_current: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Simple dotted-semver compare. Returns true if `a` > `b`.
|
||||
/// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta"
|
||||
/// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3").
|
||||
fn version_is_newer(a: &str, b: &str) -> bool {
|
||||
fn parse(v: &str) -> (Vec<u32>, bool) {
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, _)) => (c, true),
|
||||
None => (v, false),
|
||||
};
|
||||
let nums: Vec<u32> = core.split('.').filter_map(|p| p.parse().ok()).collect();
|
||||
(nums, pre)
|
||||
}
|
||||
let (na, pa) = parse(a);
|
||||
let (nb, pb) = parse(b);
|
||||
let len = na.len().max(nb.len());
|
||||
for i in 0..len {
|
||||
let ai = *na.get(i).unwrap_or(&0);
|
||||
let bi = *nb.get(i).unwrap_or(&0);
|
||||
if ai != bi { return ai > bi; }
|
||||
}
|
||||
// Cores equal — a stable (no pre) outranks a pre-release.
|
||||
!pa && pb
|
||||
}
|
||||
|
||||
/// Read the user's preferred update channel from local settings.
|
||||
/// Defaults to "stable" if never set.
|
||||
#[tauri::command]
|
||||
async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String> {
|
||||
let node = get_node(&state).await;
|
||||
let storage = node.storage.get().await;
|
||||
Ok(storage
|
||||
.get_setting("ui_update_channel")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_else(|| "stable".to_string()))
|
||||
}
|
||||
|
||||
/// Persist the user's preferred update channel.
|
||||
#[tauri::command]
|
||||
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
|
||||
if channel != "stable" && channel != "beta" {
|
||||
return Err(format!("invalid channel: {}", channel));
|
||||
}
|
||||
let node = get_node(&state).await;
|
||||
let storage = node.storage.get().await;
|
||||
storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Open a URL in the user's default system browser.
|
||||
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
|
||||
/// Only https:// URLs are accepted to avoid being a generic command exec.
|
||||
/// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener
|
||||
/// when we need the banner to work on mobile.
|
||||
#[tauri::command]
|
||||
fn open_url_external(url: String) -> Result<(), String> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err("only http(s) urls are allowed".to_string());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let res = std::process::Command::new("xdg-open").arg(&url).spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let res = std::process::Command::new("open").arg(&url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn();
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
let res: std::io::Result<std::process::Child> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"open_url_external not supported on this platform",
|
||||
));
|
||||
res.map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchorDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
|
|
@ -2907,6 +3019,10 @@ pub fn run() {
|
|||
set_anchors,
|
||||
list_anchor_peers,
|
||||
list_known_anchors,
|
||||
check_release_announcement,
|
||||
get_update_channel,
|
||||
set_update_channel,
|
||||
open_url_external,
|
||||
list_connections,
|
||||
worm_lookup,
|
||||
list_social_routes,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue