Skip to content
Open
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
4 changes: 3 additions & 1 deletion crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)?;
Expand Down
37 changes: 35 additions & 2 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 8 additions & 2 deletions web/src/features/invite/ui/InvitePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | undefined> => {
Expand Down Expand Up @@ -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"}
</a>
</p>
</div>
Expand Down
53 changes: 49 additions & 4 deletions web/src/shared/lib/buzz-download.ts
Original file line number Diff line number Diff line change
@@ -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";
};

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -142,6 +183,10 @@ export function selectBuzzDownloadUrl(
export async function resolveBuzzDownloadUrlForPlatform(
platform: BuzzDownloadPlatform,
): Promise<string> {
// 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;
Expand Down
105 changes: 69 additions & 36 deletions web/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,40 +260,40 @@ 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",
platform: "MacIntel",
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",
platform: "Linux armv8l",
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, {
Expand All @@ -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();
});