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
59 changes: 36 additions & 23 deletions crates/eidos-gui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,38 +57,51 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {

let body: Element<'a, Message> = match app.settings_tab {
SettingsTab::Nexus => {
// The validate/connect button greys out while a check is in flight.
let connect_label = if app.api_key_validating { "Checking..." } else { "Validate & Save" };
let mut connect = button(text(connect_label).size(12.0)).padding([5, 12]).style(button::primary);
if !app.api_key_validating {
connect = connect.on_press(Message::ApiKeyValidateStart);
// Sign-in, and only sign-in. There is no personal-API-key field, on
// purpose: Nexus requires personal keys absent from a distributed
// client, not merely unused, so there is nothing here to enter.
let signed_in = app.nexus_account.is_some();
let label = if app.nexus_signing_in {
"Waiting for your browser..."
} else if signed_in {
"Sign in again"
} else {
"Sign in to Nexus Mods"
};
let mut action = button(text(label).size(12.0)).padding([5, 12]).style(button::primary);
if !app.nexus_signing_in {
action = action.on_press(Message::NexusSignInStart);
}
// Masked. It is a credential, and this field sits in a window users
// screenshot to ask for help - which is one of the ways a key leaks.
// Nothing is lost by hiding it: validation names the account back, so
// the user still gets told whether what they pasted was right.
let field = text_input("Personal API key", &app.settings_api_key)
.secure(true)
.on_input(Message::ApiKeyChanged)
.on_submit(Message::ApiKeyValidateStart)
.padding(6)
.size(12.0)
.width(Length::Fill);

let mut col = Column::new()
.spacing(8)
.push(text("Personal Nexus Mods API key").size(13.0))
.push(text("Nexus Mods account").size(13.0))
.push(
text("Get it from nexusmods.com -> Account -> API Keys (Personal API Key). It is stored at ~/.config/eidos/nexus.ini and shared with the CLI.")
.size(10.0),
)
.push(Row::new().spacing(8).push(field).push(connect));
text(
"Signing in opens your browser. The session is stored at \
~/.config/eidos/nexus.ini and shared with the CLI.",
)
.size(10.0),
);

let mut row = Row::new().spacing(8).push(action);
if signed_in {
row = row.push(
button(text("Sign out").size(12.0))
.padding([5, 12])
.on_press(Message::NexusSignOut)
.style(button::secondary),
);
}
col = col.push(row);

if let Some(account) = &app.nexus_account {
let tier = if account.is_premium { "Premium" } else { "free" };
col = col.push(text(format!("Connected as {} ({tier}).", account.name)).size(11.0));
col = col.push(text(format!("Signed in as {} ({tier}).", account.name)).size(11.0));
} else {
col = col.push(text("Not signed in.").size(11.0));
}
if let Some(err) = &app.api_key_error {
if let Some(err) = &app.nexus_error {
col = col.push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A)));
}
col.into()
Expand Down
27 changes: 13 additions & 14 deletions crates/eidos-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,13 @@ enum Message {
/// Switch the Preferences tab (General / Nexus).
SettingsTabSelected(SettingsTab),
/// Edit the Nexus API key field.
ApiKeyChanged(String),
/// Validate + persist the entered Nexus API key.
ApiKeyValidateStart,
/// The key validation finished: the account on success, else an error.
/// Carries the key that was actually VALIDATED, so an edit made to the field
/// during the round-trip is never saved as if it had been checked.
ApiKeyValidateResult(String, Result<eidos_nexus::Account, String>),
/// Start the Nexus OAuth sign-in: open the browser, wait on the loopback
/// listener, exchange the code, store the session.
NexusSignInStart,
/// The sign-in finished: the account on success, else an error.
NexusSignInResult(Result<eidos_nexus::Account, String>),
/// Forget the stored Nexus session.
NexusSignOut,
/// Set the preferred colour theme.
ThemeChanged(PrefTheme),
/// Set the default game id to open (`None` = none).
Expand Down Expand Up @@ -885,14 +885,13 @@ struct App {
settings_open: bool,
/// The active Preferences tab.
settings_tab: SettingsTab,
/// The editable Nexus API key field.
settings_api_key: String,
/// The validated Nexus account, if the stored key checked out (or was cached).

/// The validated Nexus account, if a stored session checked out.
nexus_account: Option<eidos_nexus::Account>,
/// A key validation is in flight (guards the button + concurrent validations).
api_key_validating: bool,
/// The last key-validation error, shown inline in the dialog.
api_key_error: Option<String>,
/// A sign-in is in flight (guards the button + concurrent attempts).
nexus_signing_in: bool,
/// The last sign-in error, shown inline in the dialog.
nexus_error: Option<String>,
/// The persisted app-global preferences (theme, default game).
prefs: Settings,
// ---- Executables dialog ----
Expand Down
61 changes: 43 additions & 18 deletions crates/eidos-gui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,9 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
settings_tab: SettingsTab::Nexus,
// Prefill the key field from the shared store (the same key `eidos nexus
// key` writes), so it survives across sessions without a network round trip.
settings_api_key: eidos_instance::settings::load_nexus_key().unwrap_or_default(),
nexus_account: None,
api_key_validating: false,
api_key_error: None,
nexus_signing_in: false,
nexus_error: None,
prefs: Settings::load(),
executables: None,
endorsing: None,
Expand Down Expand Up @@ -214,17 +213,15 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
refresh_meta_cache(&mut app);
app.collapsed = load_collapsed(&app);
recompute_counts(&mut app);
// A stored key means the user IS connected: validate it in the background so
// the status bar shows the account instead of "not logged in" every session.
let startup = match load_nexus_api_key() {
Some(key) => Task::perform(
async move {
let result = eidos_nexus::Nexus::new(&key).validate();
(key, result)
},
|(key, result)| Message::ApiKeyValidateResult(key, result),
),
None => Task::none(),
// A stored session means the user IS signed in: validate it in the background
// so the status bar shows the account instead of "not logged in" every session.
let startup = if eidos_nexus::Nexus::have_credentials() {
Task::perform(
async move { eidos_nexus::Nexus::connect().and_then(|n| n.validate()) },
Message::NexusSignInResult,
)
} else {
Task::none()
};
(app, startup)
}
Expand Down Expand Up @@ -262,10 +259,38 @@ pub(crate) fn load_tools(app: &mut App) {
app.tools = merged;
}

/// The stored Nexus API key (the same key the CLI's `eidos nexus key` writes),
/// shared via `eidos-instance`'s settings store so the key never diverges.
pub(crate) fn load_nexus_api_key() -> Option<String> {
eidos_instance::settings::load_nexus_key()
/// Run the whole Nexus OAuth sign-in, blocking: build the PKCE challenge, hand
/// the browser the authorize URL, wait on the loopback listener for the code,
/// exchange it, and store the session.
///
/// Personal API keys are deliberately absent - Nexus requires them gone from a
/// distributed client - so this is the ONLY way Eidos authenticates, and it
/// needs a `client_id` registered with Nexus (`EIDOS_NEXUS_CLIENT_ID`).
pub(crate) fn nexus_sign_in() -> Result<eidos_nexus::Account, String> {
use eidos_nexus::oauth;
let cfg = oauth::Config::from_env().ok_or_else(|| {
"no Nexus client_id configured: set EIDOS_NEXUS_CLIENT_ID (Eidos ships no \
default, so it cannot identify itself as another application)"
.to_string()
})?;
let pkce = oauth::Pkce::new().map_err(|e| e.to_string())?;
let state = oauth::random_token(32).map_err(|e| e.to_string())?;
let url = oauth::authorize_url(&cfg, &pkce, &state);
// Hand off to the browser BEFORE listening, so a failure to open is reported
// as itself rather than as a listener timeout two minutes later.
std::process::Command::new("xdg-open")
.arg(&url)
.spawn()
.map_err(|e| format!("could not open your browser: {e}"))?;
let code = oauth::wait_for_code(cfg.redirect_port, &state, std::time::Duration::from_secs(300))?;
let tokens = oauth::exchange_code(&cfg, &code, &pkce)?;
let mut creds = eidos_instance::settings::load_nexus_creds();
creds.access_token = Some(tokens.access_token.clone());
creds.refresh_token = Some(tokens.refresh_token);
creds.expires_at = tokens.expires_at;
eidos_instance::settings::save_nexus_creds(&creds)
.map_err(|e| format!("signed in, but could not store the session: {e}"))?;
eidos_nexus::Nexus::with_bearer(&tokens.access_token).validate()
}

/// Build the Executables editor state for the open instance: the user's tools.ini
Expand Down
67 changes: 27 additions & 40 deletions crates/eidos-gui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1878,60 +1878,47 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task<Message> {
// ---- Settings / Preferences ------------------------------------------
Message::OpenSettings => {
app.menu_mod = None;
app.api_key_error = None;
// Re-read the stored key so the field reflects what's on disk.
app.settings_api_key = eidos_instance::settings::load_nexus_key().unwrap_or_default();
app.nexus_error = None;
app.settings_open = true;
}
Message::CloseSettings => {
app.settings_open = false;
app.api_key_error = None;
app.nexus_error = None;
}
Message::SettingsTabSelected(t) => app.settings_tab = t,
Message::ApiKeyChanged(s) => {
app.settings_api_key = s;
app.api_key_error = None;
}
Message::ApiKeyValidateStart => {
let key = app.settings_api_key.trim().to_string();
if key.is_empty() {
app.api_key_error = Some("Enter your personal Nexus API key.".to_string());
Message::NexusSignInStart => {
if app.nexus_signing_in {
return Task::none();
}
if app.api_key_validating {
return Task::none();
}
app.api_key_validating = true;
app.api_key_error = None;
// Blocking ureq inside the async closure, like SortPlugins.
return Task::perform(
async move {
let result = eidos_nexus::Nexus::new(&key).validate();
(key, result)
},
|(key, result)| Message::ApiKeyValidateResult(key, result),
);
app.nexus_signing_in = true;
app.nexus_error = None;
app.status = Some("Opening your browser to sign in to Nexus...".to_string());
// The whole dance on a worker: browser hand-off, loopback listener,
// code exchange. Blocking calls inside the async closure, like the
// other network work here.
return Task::perform(async move { nexus_sign_in() }, Message::NexusSignInResult);
}
Message::ApiKeyValidateResult(key, result) => {
app.api_key_validating = false;
Message::NexusSignInResult(result) => {
app.nexus_signing_in = false;
match result {
Ok(account) => {
// Persist the key that was validated (the field may have been
// edited during the round-trip) so the CLI and a relaunch see it.
let saved = eidos_instance::settings::save_nexus_key(&key);
app.status = Some(match &saved {
Ok(()) => format!(
"Connected to Nexus as {} ({}).",
account.name,
if account.is_premium { "Premium" } else { "free" }
),
Err(e) => format!("Validated, but could not save the key: {e}"),
});
app.status = Some(format!(
"Signed in to Nexus as {} ({}).",
account.name,
if account.is_premium { "Premium" } else { "free" }
));
app.nexus_account = Some(account);
}
Err(e) => {
app.api_key_error = Some(e);
Err(e) => app.nexus_error = Some(e),
}
}
Message::NexusSignOut => {
match eidos_instance::settings::clear_nexus_tokens() {
Ok(()) => {
app.nexus_account = None;
app.status = Some("Signed out of Nexus.".to_string());
}
Err(e) => app.nexus_error = Some(format!("could not sign out: {e}")),
}
}
Message::ThemeChanged(t) => {
Expand Down
Loading
Loading