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
70 changes: 59 additions & 11 deletions desktop/src-tauri/src/builderlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz";
#[derive(Default)]
pub(crate) struct BuilderlabSession(Mutex<Option<StoredSession>>);

#[derive(Default)]
pub(crate) struct BuilderlabLogin(Mutex<Option<PendingLogin>>);

struct PendingLogin {
id: uuid::Uuid,
cancel: oneshot::Sender<()>,
}

struct StoredSession {
credential: String,
}
Expand Down Expand Up @@ -130,6 +138,7 @@ pub(crate) async fn start_builderlab_login(
app: tauri::AppHandle,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
login: tauri::State<'_, BuilderlabLogin>,
) -> Result<BuilderlabAuthInfo, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
Expand Down Expand Up @@ -158,19 +167,38 @@ pub(crate) async fn start_builderlab_login(
return Err(format!("could not open Builderlab authentication: {error}"));
}

let exchange_code = match tokio::time::timeout(LOGIN_TIMEOUT, receiver).await {
Ok(Ok(Ok(code))) => code,
Ok(Ok(Err(error))) => {
server.abort();
return Err(error);
}
Ok(Err(_)) => {
server.abort();
return Err("local authentication callback stopped unexpectedly".to_owned());
let login_id = uuid::Uuid::new_v4();
let (cancel_sender, mut cancel_receiver) = oneshot::channel();
{
let mut pending = login.0.lock().map_err(|error| error.to_string())?;
if let Some(previous) = pending.take() {
let _ = previous.cancel.send(());
}
Err(_) => {
*pending = Some(PendingLogin {
id: login_id,
cancel: cancel_sender,
});
}

let exchange_code = tokio::select! {
result = tokio::time::timeout(LOGIN_TIMEOUT, receiver) => match result {
Ok(Ok(Ok(code))) => code,
Ok(Ok(Err(error))) => {
server.abort();
return Err(error);
}
Ok(Err(_)) => {
server.abort();
return Err("local authentication callback stopped unexpectedly".to_owned());
}
Err(_) => {
server.abort();
return Err("Builderlab authentication timed out".to_owned());
}
},
_ = &mut cancel_receiver => {
server.abort();
return Err("Builderlab authentication timed out".to_owned());
return Err("Builderlab authentication canceled".to_owned());
}
};
server.abort();
Expand Down Expand Up @@ -206,6 +234,16 @@ pub(crate) async fn start_builderlab_login(
email: me.email,
name: me.name,
};
{
let mut pending = login.0.lock().map_err(|error| error.to_string())?;
if pending
.as_ref()
.is_none_or(|pending| pending.id != login_id)
{
return Err("Builderlab authentication canceled".to_owned());
}
*pending = None;
}
*session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession {
credential: exchanged.session_credential,
});
Expand Down Expand Up @@ -242,6 +280,16 @@ pub(crate) async fn get_builderlab_auth(
}
}

#[tauri::command]
pub(crate) fn cancel_builderlab_login(
login: tauri::State<'_, BuilderlabLogin>,
) -> Result<(), String> {
if let Some(pending) = login.0.lock().map_err(|error| error.to_string())?.take() {
let _ = pending.cancel.send(());
}
Ok(())
}

#[tauri::command]
pub(crate) fn clear_builderlab_auth(
session: tauri::State<'_, BuilderlabSession>,
Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,7 @@ pub fn run() {
.build()
});

// Only register the updater in release builds that were compiled with a
// real updater configuration. Local unsigned builds omit that config and
// should still launch for debugging.
// Register the updater only in configured release builds; omit it locally.
#[cfg(buzz_updater_enabled)]
let builder = if cfg!(debug_assertions) {
builder
Expand All @@ -451,6 +449,7 @@ pub fn run() {
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.setup(move |app| {
let app_handle = app.handle().clone();
Expand Down Expand Up @@ -744,6 +743,7 @@ pub fn run() {
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
start_builderlab_login,
cancel_builderlab_login,
get_builderlab_auth,
clear_builderlab_auth,
get_builderlab_nostr_identity,
Expand Down
164 changes: 164 additions & 0 deletions desktop/src/features/communities/hostedCommunityApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { invoke } from "@tauri-apps/api/core";

export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz";
export const HOSTED_COMMUNITY_LIMIT = 3;
export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

export type BuilderlabAuth = {
email?: string;
name?: string;
expiresAt: string;
};

export type HostedCommunityApiError = {
code?: string;
message?: string;
setup_needed?: boolean;
};

export type HostedNostrIdentity = {
npub?: string;
pubkey_hex?: string;
};

export type HostedIdentityResponse = {
identity?: HostedNostrIdentity;
error?: HostedCommunityApiError;
correlation_id?: string;
};

export type HostedCommunity = {
id?: string;
name?: string;
slug?: string;
normalized_host?: string;
owner_pubkey?: string;
archived_at?: string | null;
};

export type HostedCommunitiesResponse = {
communities?: HostedCommunity[];
error?: HostedCommunityApiError;
correlation_id?: string;
};

export type HostedCommunityAvailabilityResponse = {
available?: boolean;
normalized_host?: string;
error?: HostedCommunityApiError;
correlation_id?: string;
};

export type HostedCommunityMutationResponse = {
community?: HostedCommunity;
error?: HostedCommunityApiError;
correlation_id?: string;
};

export type HostedCommunityAccount = {
communities: HostedCommunity[];
identity: HostedNostrIdentity | null;
};

export function hostedCommunityErrorMessage(
error: HostedCommunityApiError | undefined,
correlationId: string | undefined,
fallback: string,
) {
const messages: Record<string, string> = {
missing_mapping: "Connect your Buzz identity before creating a community.",
invalid_name: "Use lowercase letters, numbers, and hyphens.",
taken: "That Buzz address is already taken.",
limit_reached: `You've reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.`,
relay_unavailable: "Community provisioning is temporarily unavailable.",
identity_already_bound:
"This Builderlab account is connected to another Buzz identity.",
pubkey_already_bound:
"This Buzz identity is connected to another Builderlab account.",
not_owner: "Only the community owner can do that.",
transferee_not_registered:
"That person needs a connected Buzz identity before you can transfer ownership to them.",
};
const message = messages[error?.code ?? ""] ?? error?.message ?? fallback;
return correlationId
? `${message} Correlation ID: ${correlationId}`
: message;
}

export function hostedCommunityRelayUrl(community: HostedCommunity) {
const host = community.normalized_host?.trim();
return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null;
}

export function getBuilderlabAuth() {
return invoke<BuilderlabAuth | null>("get_builderlab_auth");
}

export function cancelBuilderlabLogin() {
return invoke<void>("cancel_builderlab_login");
}

export function clearBuilderlabAuth() {
return invoke<void>("clear_builderlab_auth");
}

export function startBuilderlabLogin() {
return invoke<BuilderlabAuth>("start_builderlab_login");
}

export async function loadHostedCommunityAccount(): Promise<HostedCommunityAccount> {
const [identityResponse, communitiesResponse] = await Promise.all([
invoke<HostedIdentityResponse>("get_builderlab_nostr_identity"),
invoke<HostedCommunitiesResponse>("list_builderlab_communities"),
]);
if (
identityResponse.error &&
identityResponse.error.code !== "unauthorized" &&
!identityResponse.error.setup_needed
) {
throw new Error(
hostedCommunityErrorMessage(
identityResponse.error,
identityResponse.correlation_id,
"Could not load the connected Buzz identity.",
),
);
}
if (communitiesResponse.error && !communitiesResponse.error.setup_needed) {
throw new Error(
hostedCommunityErrorMessage(
communitiesResponse.error,
communitiesResponse.correlation_id,
"Could not load communities.",
),
);
}
return {
identity: identityResponse.identity ?? null,
communities: communitiesResponse.communities ?? [],
};
}

export function bindBuilderlabIdentity() {
return invoke<HostedIdentityResponse>("bind_builderlab_nostr_identity");
}

export function deleteBuilderlabIdentity() {
return invoke<HostedIdentityResponse>("delete_builderlab_nostr_identity");
}

export function checkHostedCommunityName(name: string) {
return invoke<HostedCommunityAvailabilityResponse>(
"check_builderlab_community_name",
{ name },
);
}

export function createHostedCommunity(name: string) {
return invoke<HostedCommunityMutationResponse>(
"create_builderlab_community",
{
name,
},
);
}
Loading
Loading