feat(tauri-app): Einladungscode im Client, Produktions-URLs als Default
Zwei Dinge, die einen ausgelieferten Client heute unbrauchbar gemacht hätten. Erstens: Seit die öffentliche Instanz Einladungscodes verlangt, bekam jeder Registrierungsversuch aus dem Client 400 InvalidInviteCode, ohne dass es ein Feld für einen Code gegeben hätte. auth_register und create_account nehmen ihn jetzt entgegen, der LoginScreen zeigt das Feld nur im Registrieren-Modus und leert es beim Moduswechsel — sonst würde ein getippter Code im ausgeblendeten Feld überleben und beim nächsten Versuch stillschweigend mitgehen. Ein leeres Feld wird weggelassen, nicht abgelehnt: ob ein Code nötig ist, entscheidet der Server (PDS_INVITE_REQUIRED), und eine lokale Dev-PDS verlangt keinen. "Kein Code angegeben" und "mein Code ist der Leerstring" sind zwei verschiedene Aussagen; nur die erste ist je wahr. Deshalb fliegt der Schlüssel per skip_serializing_if ganz aus dem Body. Der Server unterscheidet bei der Ablehnung bewusst nicht zwischen fehlend, falsch, gesperrt und verbraucht — sonst wären Codes enumerierbar. Die Meldung im Client nennt deshalb beide plausiblen Auswege, statt einen zu raten. Zweitens: Die Basis-URLs fielen ohne Umgebungsvariable auf http://127.0.0.1:2583 bzw. :2584 zurück. Ein gebautes Paket hätte also gegen nichts gesprochen. Beide zeigen jetzt auf https://tweet.maarcade.com — dieselbe Origin für PDS und AppView, der Proxy trennt nach Pfadpräfix. Die Variablen überschreiben weiterhin, damit Entwicklung gegen localhost möglich bleibt; eine leer gesetzte Variable gilt dabei als ungesetzt, weil eine leere Basis-URL sonst als kryptischer Relative-URL-Fehler weit weg von der Ursache auftaucht. Der Settings-View spiegelte dieselben alten Defaults und hätte falsche Backends angezeigt — mitgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
f04d63dd7b
commit
73959f9dde
@@ -34,15 +34,32 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::V
|
||||
state.pds.describe_server().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `auth_register(handle, password, inviteCode?)` — create an account
|
||||
/// on the configured PDS and store the resulting session.
|
||||
///
|
||||
/// `invite_code` arrives from the frontend as `inviteCode` (Tauri maps
|
||||
/// camelCase JS argument keys onto snake_case Rust parameters, the same
|
||||
/// way `mark_notifications_seen` receives `seenAt`). It is `Option`
|
||||
/// because the invite gate is a *server* setting
|
||||
/// (`PDS_INVITE_REQUIRED`): the public instance at
|
||||
/// `https://tweet.maarcade.com` demands a code, a locally run dev PDS
|
||||
/// usually does not, and the client has no business deciding which.
|
||||
/// When no code is given the field is dropped from the request body
|
||||
/// rather than sent empty — see [`pds_client::CreateAccountReq`].
|
||||
///
|
||||
/// A refused code surfaces as the PDS's `400
|
||||
/// {"error":"InvalidInviteCode", …}` inside the stringified error, and
|
||||
/// `errorMessage()` in `client.ts` turns that into German copy.
|
||||
#[tauri::command]
|
||||
async fn auth_register(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
password: String,
|
||||
invite_code: Option<String>,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_account(&handle, &password)
|
||||
.create_account(&handle, &password, invite_code.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
@@ -768,16 +785,74 @@ async fn show_notification(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default PDS base URL — the public instance.
|
||||
///
|
||||
/// This is what a *shipped* build talks to. It used to be
|
||||
/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to
|
||||
/// anyone but the developer pointed at a server that does not exist on
|
||||
/// their machine: every call failed with a connection error and the
|
||||
/// login screen could not even render `describeServer`. A default is
|
||||
/// the configuration of the people who never set one, so it has to be
|
||||
/// the production deployment.
|
||||
///
|
||||
/// No trailing slash: [`PdsHttpClient`] builds its endpoints as
|
||||
/// `{base}/xrpc/com.atproto.…`, so the base must end at the host.
|
||||
const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com";
|
||||
|
||||
/// Default AppView base URL. Same host as the PDS — the reverse proxy
|
||||
/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…`
|
||||
/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends
|
||||
/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)`
|
||||
/// calls in `appview_client.rs`), so the two clients can and must
|
||||
/// share the one origin.
|
||||
const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com";
|
||||
|
||||
/// Resolve one base URL from its environment variable, falling back to
|
||||
/// the compiled-in default.
|
||||
///
|
||||
/// **The environment always wins.** Development runs against a local
|
||||
/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and
|
||||
/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what
|
||||
/// `scripts/` and the dev docker-compose set up — and pointing the
|
||||
/// desktop client at it must stay a matter of exporting two variables,
|
||||
/// never of rebuilding. Only an *unset* variable takes the production
|
||||
/// default.
|
||||
///
|
||||
/// A variable set to whitespace (or the empty string) counts as unset:
|
||||
/// an empty base URL would silently produce request URLs like
|
||||
/// `/xrpc/…` with no host, and `reqwest` would reject them as a
|
||||
/// relative-URL error far away from the actual mistake. Trailing
|
||||
/// slashes are trimmed because both clients append an absolute path to
|
||||
/// this string, and `https://host//api/x` is not the same route to
|
||||
/// every proxy.
|
||||
///
|
||||
/// Takes the already-performed lookup rather than the variable name so
|
||||
/// it stays a pure function — testable without mutating the process
|
||||
/// environment, and without a Tauri runtime.
|
||||
fn base_url_or_default(from_env: Result<String, std::env::VarError>, default: &str) -> String {
|
||||
let configured = from_env.ok();
|
||||
let trimmed = configured
|
||||
.as_deref()
|
||||
.map(|v| v.trim().trim_end_matches('/'))
|
||||
.filter(|v| !v.is_empty());
|
||||
trimmed.unwrap_or(default).to_string()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
|
||||
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
let pds_url = base_url_or_default(
|
||||
std::env::var("MAARCADETWEET_PDS_URL"),
|
||||
DEFAULT_PDS_URL,
|
||||
);
|
||||
let appview_url = base_url_or_default(
|
||||
std::env::var("MAARCADETWEET_APPVIEW_URL"),
|
||||
DEFAULT_APPVIEW_URL,
|
||||
);
|
||||
tracing::info!(%pds_url, %appview_url, "resolved backend base URLs");
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url.clone()),
|
||||
@@ -1056,3 +1131,75 @@ async fn profile_set(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env::VarError;
|
||||
|
||||
/// The whole point of the change: a build with nothing configured
|
||||
/// must talk to the public instance, not to a loopback port that
|
||||
/// only exists on a developer's laptop. Pinned as a literal so a
|
||||
/// well-meant "let's default back to localhost for dev" has to
|
||||
/// argue with a red test first.
|
||||
#[test]
|
||||
fn unset_env_falls_back_to_the_public_instance() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
// Both services live behind the same origin — the proxy splits
|
||||
// them by path prefix (`/xrpc/` vs `/api/`), which the clients
|
||||
// append themselves.
|
||||
assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL);
|
||||
assert!(!DEFAULT_PDS_URL.ends_with('/'));
|
||||
}
|
||||
|
||||
/// Development against a local stack has to keep working by
|
||||
/// exporting a variable, so a set value always beats the default.
|
||||
#[test]
|
||||
fn env_var_overrides_the_default() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL),
|
||||
"http://127.0.0.1:2583"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL),
|
||||
"http://127.0.0.1:2584"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty or whitespace-only variable is a misconfiguration, not
|
||||
/// a request for an empty base URL: `reqwest` would answer the
|
||||
/// resulting host-less URL with a relative-URL error nowhere near
|
||||
/// the cause. Trailing slashes go because both clients append an
|
||||
/// absolute path (`{base}/xrpc/…`, `{base}/api/…`).
|
||||
#[test]
|
||||
fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("".into()), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL),
|
||||
"http://127.0.0.1:2584"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
// A non-UTF-8 variable is as unusable as an unset one.
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,27 @@ pub struct CreateAccountReq {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
/// Invite code, required by the PDS whenever it runs with
|
||||
/// `PDS_INVITE_REQUIRED=true` (the public instance at
|
||||
/// `https://tweet.maarcade.com` does). Rejected codes come back as
|
||||
/// `400 {"error":"InvalidInviteCode", …}`.
|
||||
///
|
||||
/// **Wire name.** The server's `CreateAccountReq`
|
||||
/// (`crates/pds-server/src/routes/types.rs`) is snake_case with an
|
||||
/// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto
|
||||
/// clients. We are not one of those: every body this client sends
|
||||
/// is snake_case (`refresh_jwt` in `refresh_session`, `handle` /
|
||||
/// `password` right here), so `invite_code` is the field name that
|
||||
/// matches the rest of the file. The alias exists for other people.
|
||||
///
|
||||
/// **Skipped when `None`.** Same reasoning as `validate` below —
|
||||
/// a PDS with the invite gate *off* must keep accepting our
|
||||
/// registrations, and sending `"invite_code": null` (let alone
|
||||
/// `""`) would be claiming the user supplied something. Omitting
|
||||
/// the key leaves the server's `Option` at `None`, which is exactly
|
||||
/// "the user gave no code".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -130,15 +151,31 @@ impl PdsHttpClient {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// `com.atproto.server.createAccount`.
|
||||
///
|
||||
/// `invite_code` is `None` when the user left the field empty; the
|
||||
/// key is then left out of the body entirely (see
|
||||
/// [`CreateAccountReq::invite_code`]) so a PDS running without the
|
||||
/// invite gate still registers the account. A code that only
|
||||
/// contains whitespace is treated as absent for the same reason —
|
||||
/// the server's `invite::normalize` trims before looking it up, so
|
||||
/// `" "` could never match a real code anyway, and passing it on
|
||||
/// would only turn "you forgot the field" into "your code is
|
||||
/// wrong".
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
handle: &str,
|
||||
password: &str,
|
||||
invite_code: Option<&str>,
|
||||
) -> Result<AccountSession> {
|
||||
let body = CreateAccountReq {
|
||||
handle: handle.to_string(),
|
||||
email: None,
|
||||
password: password.to_string(),
|
||||
invite_code: invite_code
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
let r = self
|
||||
.client
|
||||
|
||||
Reference in New Issue
Block a user