diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs
index 9f41fbf751c..8d0f4841916 100644
--- a/crates/buzz-cli/src/commands/messages.rs
+++ b/crates/buzz-cli/src/commands/messages.rs
@@ -6,7 +6,8 @@ use crate::client::{normalize_events, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
- validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
+ validate_content_not_empty, validate_content_size, validate_hex64, validate_uuid,
+ MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
@@ -617,6 +618,7 @@ pub async fn cmd_send_message(
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
+ validate_content_not_empty(&p.content)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs
index 4985b441417..5261ee0e3cb 100644
--- a/crates/buzz-cli/src/validate.rs
+++ b/crates/buzz-cli/src/validate.rs
@@ -65,13 +65,25 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> {
if content.len() > MAX_CONTENT_BYTES {
return Err(CliError::Usage(format!(
"content exceeds maximum size ({} > {} bytes)",
- content.len(),
- MAX_CONTENT_BYTES
+ content.len(), MAX_CONTENT_BYTES
)));
}
Ok(())
}
+/// Reject empty or whitespace-only content.
+///
+/// Piped empty stdin or `printf '\n' | buzz messages send --content -`
+/// should fail before signing or relay submission (see #5744).
+pub fn validate_content_not_empty(content: &str) -> Result<(), CliError> {
+ if content.trim().is_empty() {
+ return Err(CliError::Usage(
+ "message content must not be empty or whitespace-only".into(),
+ ));
+ }
+ Ok(())
+}
+
/// Percent-encode for URL path segments and query parameter values.
/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
#[cfg(test)]
@@ -276,6 +288,27 @@ mod tests {
assert!(validate_content_size("").is_ok());
}
+ // --- validate_content_not_empty ---
+
+ #[test]
+ fn validate_content_not_empty_rejects_empty() {
+ assert!(validate_content_not_empty("").is_err());
+ }
+
+ #[test]
+ fn validate_content_not_empty_rejects_whitespace_only() {
+ assert!(validate_content_not_empty(" ").is_err());
+ assert!(validate_content_not_empty("\n").is_err());
+ assert!(validate_content_not_empty("\t\n\r").is_err());
+ }
+
+ #[test]
+ fn validate_content_not_empty_accepts_content() {
+ assert!(validate_content_not_empty("hello").is_ok());
+ assert!(validate_content_not_empty(" hello ").is_ok());
+ assert!(validate_content_not_empty("\nhello\n").is_ok());
+ }
+
// --- percent_encode ---
#[test]
diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx
index 0033e6cad5c..31aaf047611 100644
--- a/web/src/features/invite/ui/InvitePage.tsx
+++ b/web/src/features/invite/ui/InvitePage.tsx
@@ -2,6 +2,8 @@ import buzzAppIcon from "@/assets/app-icon@3x.png";
import { claimInviteInBrowser } from "@/features/invite/invite-api";
import {
BUZZ_RELEASES_URL,
+ BUZZ_APP_STORE_URL,
+ BUZZ_PLAY_STORE_URL,
type BuzzDownloadPlatform,
detectBuzzDownloadPlatform,
resolveBuzzDownloadUrlForPlatform,
@@ -86,7 +88,7 @@ export function InvitePage({ code }: { code: string }) {
const config = (await response.json()) as { policy?: JoinPolicy };
setPolicy(config.policy ?? null);
})
- .catch(() => setPolicy(undefined));
+ .catch(() => setPolicy(null));
}, []);
const acceptPolicy = async (): Promise => {
@@ -286,7 +288,11 @@ export function InvitePage({ code }: { code: string }) {
setShowMacChoice(true);
}}
>
- Download it now
+ {downloadUrl === BUZZ_APP_STORE_URL
+ ? "Get it on the App Store"
+ : downloadUrl === BUZZ_PLAY_STORE_URL
+ ? "Get it on Google Play"
+ : "Download it now"}
diff --git a/web/src/shared/lib/buzz-download.ts b/web/src/shared/lib/buzz-download.ts
index 3c198382b44..b499bcc7123 100644
--- a/web/src/shared/lib/buzz-download.ts
+++ b/web/src/shared/lib/buzz-download.ts
@@ -1,11 +1,21 @@
export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases";
+export const BUZZ_APP_STORE_URL =
+ "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271";
+export const BUZZ_PLAY_STORE_URL =
+ "https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile";
const BUZZ_RELEASES_API_URL =
"https://api.github.com/repos/block/buzz/releases?per_page=10";
const CACHE_KEY = "buzz.latestDownload.v1";
const CACHE_TTL_MS = 60 * 60 * 1000;
export type BuzzDownloadPlatform = {
- operatingSystem: "linux" | "macos" | "windows" | "unknown";
+ operatingSystem:
+ | "linux"
+ | "macos"
+ | "windows"
+ | "ios"
+ | "android"
+ | "unknown";
architecture: "arm64" | "x64" | "unknown";
};
@@ -36,13 +46,28 @@ function normalizeOperatingSystem(
// Compatibility tokens are treacherous: iPadOS can report MacIntel and a
// Macintosh UA, while Android and ChromeOS expose Linux platform strings.
- // Reject non-desktop devices before admitting desktop-looking signals.
+ // Detect specific mobile platforms first, then reject other non-desktop devices.
const isIPadDesktopMode =
platform === "macintel" && navigatorValue.maxTouchPoints > 1;
+
+ // Detect iOS (iPhone, iPad, iPod)
+ if (
+ /iphone|ipad|ipod/.test(userAgent) ||
+ platform === "iphone" ||
+ isIPadDesktopMode
+ ) {
+ return "ios";
+ }
+
+ // Detect Android
+ if (/android/.test(userAgent)) {
+ return "android";
+ }
+
+ // Reject other non-desktop devices (tablets, feature phones, etc.)
const isUnsupportedDevice =
userAgentData?.mobile === true ||
- isIPadDesktopMode ||
- /android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test(
+ /mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test(
userAgent,
);
if (isUnsupportedDevice) return "unknown";
@@ -124,10 +149,26 @@ function assetPattern(platform: BuzzDownloadPlatform): RegExp | undefined {
}
}
+/** Return the appropriate app store URL for a mobile platform, if known. */
+function mobileStoreUrl(platform: BuzzDownloadPlatform): string | undefined {
+ switch (platform.operatingSystem) {
+ case "ios":
+ return BUZZ_APP_STORE_URL;
+ case "android":
+ return BUZZ_PLAY_STORE_URL;
+ default:
+ return undefined;
+ }
+}
+
export function selectBuzzDownloadUrl(
releases: GitHubRelease[],
platform: BuzzDownloadPlatform,
): string | undefined {
+ // Mobile platforms always go to the appropriate app store.
+ const mobileUrl = mobileStoreUrl(platform);
+ if (mobileUrl) return mobileUrl;
+
const pattern = assetPattern(platform);
if (!pattern) return undefined;
@@ -142,6 +183,10 @@ export function selectBuzzDownloadUrl(
export async function resolveBuzzDownloadUrlForPlatform(
platform: BuzzDownloadPlatform,
): Promise {
+ // Mobile platforms resolve to a fixed store URL — no API call needed.
+ const mobileUrl = mobileStoreUrl(platform);
+ if (mobileUrl) return mobileUrl;
+
try {
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as {
expiresAt: number;
diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts
index 22e26281656..1a0dd083e4c 100644
--- a/web/tests/e2e/smoke.spec.ts
+++ b/web/tests/e2e/smoke.spec.ts
@@ -260,16 +260,18 @@ test("invite asks Safari users to choose their Mac download", async ({
await context.close();
});
-test("invite download falls back for mobile and non-desktop devices", async ({
+test("invite download links iOS users to App Store and Android to Play Store", async ({
browser,
}) => {
- const unsupportedDevices = [
+ const mobileDevices = [
{
name: "iPhone Safari",
platform: "iPhone",
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15",
maxTouchPoints: 5,
+ expectedHref:
+ "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271",
},
{
name: "iPadOS desktop mode",
@@ -277,6 +279,8 @@ test("invite download falls back for mobile and non-desktop devices", async ({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15",
maxTouchPoints: 5,
+ expectedHref:
+ "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271",
},
{
name: "Android phone",
@@ -284,16 +288,12 @@ test("invite download falls back for mobile and non-desktop devices", async ({
userAgent:
"Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 Mobile",
maxTouchPoints: 5,
- },
- {
- name: "ChromeOS",
- platform: "Linux x86_64",
- userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36",
- maxTouchPoints: 0,
+ expectedHref:
+ "https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile",
},
];
- for (const device of unsupportedDevices) {
+ for (const device of mobileDevices) {
const context = await browser.newContext({ userAgent: device.userAgent });
await context.addInitScript(({ platform, maxTouchPoints }) => {
Object.defineProperties(navigator, {
@@ -313,37 +313,70 @@ test("invite download falls back for mobile and non-desktop devices", async ({
body: JSON.stringify({ policy: null }),
});
});
- await page.route("https://api.github.com/**", async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "Access-Control-Allow-Origin": "*" },
- body: JSON.stringify([
- {
- draft: false,
- prerelease: false,
- assets: [
- {
- name: "Buzz_0.4.9_x64.dmg",
- browser_download_url:
- "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg",
- },
- {
- name: "Buzz_0.4.9_amd64.AppImage",
- browser_download_url:
- "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
- },
- ],
- },
- ]),
- });
- });
await page.goto("/invite/demo-code");
await expect(
- page.getByRole("link", { name: "Download it now" }),
+ page.getByRole("link", {
+ name: /Get it on the App Store|Get it on Google Play/,
+ }),
device.name,
- ).toHaveAttribute("href", "https://github.com/block/buzz/releases");
+ ).toHaveAttribute("href", device.expectedHref);
await context.close();
}
});
+
+test("invite download falls back for non-phone unsupported devices", async ({
+ browser,
+}) => {
+ const context = await browser.newContext({
+ userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36",
+ });
+ await context.addInitScript(() => {
+ Object.defineProperties(navigator, {
+ platform: { configurable: true, value: "Linux x86_64" },
+ maxTouchPoints: { configurable: true, value: 0 },
+ userAgentData: {
+ configurable: true,
+ value: { platform: "Linux x86_64", mobile: false },
+ },
+ });
+ });
+ const page = await context.newPage();
+ await page.route("**/api/join-policy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ policy: null }),
+ });
+ });
+ await page.route("https://api.github.com/**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "Access-Control-Allow-Origin": "*" },
+ body: JSON.stringify([
+ {
+ draft: false,
+ prerelease: false,
+ assets: [
+ {
+ name: "Buzz_0.4.9_amd64.AppImage",
+ browser_download_url:
+ "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
+ },
+ ],
+ },
+ ]),
+ });
+ });
+
+ await page.goto("/invite/demo-code");
+ await expect(
+ page.getByRole("link", { name: "Download it now" }),
+ "ChromeOS",
+ ).toHaveAttribute(
+ "href",
+ "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
+ );
+ await context.close();
+});