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
87 changes: 65 additions & 22 deletions crates/eidos-gui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ impl std::fmt::Display for ThemeChoice {
}
}

/// The cached "show adult content" answer for the signed-in account: `Some(true)`
/// shown, `Some(false)` turned off by the user, `None` not known.
///
/// Read straight from the credential store rather than plumbed through app state,
/// because that store IS what the client consults - a second copy in the UI could
/// disagree with what is actually being withheld.
fn adult_content_state() -> Option<bool> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
eidos_instance::settings::load_nexus_creds().adult_pref(now)
}

pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {
let header = Row::new()
.spacing(6)
Expand Down Expand Up @@ -178,29 +192,58 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {
.into()
}
SettingsTab::Nexus => {
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);
}
let mut controls = Row::new().spacing(8).push(action);
if signed_in {
controls = controls.push(
button(text("Sign out").size(12.0))
.padding([5, 12])
.on_press(Message::NexusSignOut)
.style(button::secondary),
);
}
// 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.
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 account = Column::new()
.spacing(6)
.push(Row::new().spacing(8).push(field).push(connect));
if let Some(a) = &app.nexus_account {
let tier = if a.is_premium { "Premium" } else { "free" };
account = account.push(text(format!("Connected as {} ({tier}).", a.name)).size(11.0));

let mut account = Column::new().spacing(6).push(controls);
match &app.nexus_account {
Some(a) => {
let tier = if a.is_premium { "Premium" } else { "free" };
account =
account.push(text(format!("Signed in as {} ({tier}).", a.name)).size(11.0));
// Say what is being withheld and why. Adult mods coming back
// blank with no explanation reads as Eidos being broken, and
// "could not check" is the case the user can actually act on.
account = account.push(
text(match adult_content_state() {
Some(true) => "Adult content: shown (enabled on your Nexus account).",
Some(false) => {
"Adult content: hidden. It is turned off on your Nexus account; \
change it on nexusmods.com, then sign in again here."
}
None => {
"Adult content: hidden. Eidos could not read your Nexus content \
settings, so it withholds adult mods until it can."
}
})
.size(10.0),
);
}
None => account = account.push(text("Not signed in.").size(11.0)),
}
if let Some(err) = &app.api_key_error {
if let Some(err) = &app.nexus_error {
account = account
.push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A)));
}
Expand All @@ -214,7 +257,7 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> {
Column::new()
.spacing(6)
.push(
text("From nexusmods.com -> Account -> API Keys. Stored in nexus.ini and shared with the CLI.")
text("Signing in opens your browser. The session is stored in nexus.ini and shared with the CLI.")
.size(10.0),
)
.push(account)
Expand Down
26 changes: 12 additions & 14 deletions crates/eidos-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,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 @@ -966,14 +966,12 @@ struct App {
/// Which collapsible sections of the Settings screen are open. Keyed by the
/// same `&'static str` the section is built with, so a rename cannot drift.
settings_expanded: HashSet<&'static str>,
/// 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 @@ -115,10 +115,9 @@ pub(crate) fn new(launch_command: Vec<String>) -> (App, Task<Message>) {
settings_expanded: SettingsTab::DEFAULT_OPEN.iter().copied().collect(),
// 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 @@ -219,17 +218,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 @@ -267,10 +264,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 @@ -1879,60 +1879,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::DragScrollSpeedChanged(v) => {
Expand Down
Loading
Loading