Skip to main content

rpfm_server/
translation_hub.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Submission of translations to the Translation Hub, and the GitHub sign-in it needs.
12//!
13//! The GitHub token is kept in the OS keyring and never leaves the server: frontends only
14//! see the sign-in state and the account's login.
15
16use anyhow::{anyhow, Result};
17use keyring::{Entry, Error as KeyringError};
18
19use std::fs;
20
21use rpfm_extensions::translator::PackTranslation;
22use rpfm_extensions::translator::hub::SubmissionResult;
23
24use rpfm_ipc::messages::GitHubSignInState;
25use rpfm_ipc::settings_keys::GITHUB_LOGIN;
26
27use rpfm_lib::error::RLibError;
28use rpfm_lib::games::{TRANSLATIONS_REPO_NAME, TRANSLATIONS_REPO_OWNER};
29use rpfm_lib::integrations::github::{self, DeviceCode, DeviceFlowPoll, GitHubClient, PUBLIC_REPO_SCOPE};
30
31use crate::GITHUB_OAUTH_CLIENT_ID;
32use crate::settings::{mutate_settings, translations_local_path, SETTINGS};
33
34/// Service name of RPFM's entries in the OS keyring.
35const KEYRING_SERVICE: &str = "rpfm";
36
37/// Name of the keyring entry holding the GitHub token.
38const KEYRING_GITHUB_TOKEN: &str = "github_token";
39
40/// Environment variable to submit to another repository (`owner/name`) instead of the real hub. Debug builds only.
41#[cfg(debug_assertions)]
42const HUB_OVERRIDE_VAR: &str = "RPFM_TRANSLATION_HUB";
43
44//-------------------------------------------------------------------------------//
45//                             Enums & Structs
46//-------------------------------------------------------------------------------//
47
48/// Result of trying to submit a translation.
49pub enum SubmitOutcome {
50
51    /// The translation was submitted.
52    Submitted(SubmissionResult),
53
54    /// The user has to sign in to GitHub first, because they never did or GitHub rejected their sign-in.
55    SignInRequired,
56}
57
58//-------------------------------------------------------------------------------//
59//                             Implementations
60//-------------------------------------------------------------------------------//
61
62/// Start a GitHub sign-in with the device flow.
63///
64/// # Errors
65///
66/// Returns an error if this build has no GitHub client ID, or the request fails.
67pub fn sign_in_start() -> Result<DeviceCode> {
68    if GITHUB_OAUTH_CLIENT_ID.is_empty() {
69        return Err(anyhow!("This build of RPFM can't sign in to GitHub, as it was built without a GitHub client ID."));
70    }
71
72    github::request_device_code(GITHUB_OAUTH_CLIENT_ID, PUBLIC_REPO_SCOPE).map_err(From::from)
73}
74
75/// Check a GitHub sign-in. Once approved, keeps the token in the OS keyring and the account's login in the settings.
76///
77/// # Arguments
78///
79/// * `device_code` - Device code of the sign-in, from [`sign_in_start`].
80///
81/// # Errors
82///
83/// Returns an error if the request fails, or the token can't be stored in the keyring.
84pub fn sign_in_poll(device_code: &str) -> Result<GitHubSignInState> {
85    Ok(match github::poll_device_flow(GITHUB_OAUTH_CLIENT_ID, device_code)? {
86        DeviceFlowPoll::Pending => GitHubSignInState::Pending,
87        DeviceFlowPoll::SlowDown(interval) => GitHubSignInState::SlowDown(interval),
88        DeviceFlowPoll::Expired => GitHubSignInState::Expired,
89        DeviceFlowPoll::Denied => GitHubSignInState::Denied,
90        DeviceFlowPoll::Granted(token) => {
91            let login = GitHubClient::new(&token)?.user_login()?;
92            keyring_entry()?.set_password(&token).map_err(keyring_error)?;
93            mutate_settings(|settings| settings.set_string(GITHUB_LOGIN, &login))?;
94            GitHubSignInState::SignedIn(login)
95        },
96    })
97}
98
99/// Login of the GitHub account the user is signed in as.
100///
101/// # Returns
102///
103/// The login, or `None` if not signed in.
104///
105/// # Errors
106///
107/// Returns an error if the keyring can't be read.
108pub fn account() -> Result<Option<String>> {
109    if token()?.is_none() {
110        return Ok(None);
111    }
112
113    let login = SETTINGS.read().unwrap().string(GITHUB_LOGIN);
114    Ok(Some(login))
115}
116
117/// Sign out of GitHub, deleting the stored token and login.
118///
119/// # Errors
120///
121/// Returns an error if the keyring or the settings can't be written.
122pub fn sign_out() -> Result<()> {
123    match keyring_entry()?.delete_credential() {
124        Ok(()) | Err(KeyringError::NoEntry) => {},
125        Err(error) => return Err(keyring_error(error)),
126    }
127
128    mutate_settings(|settings| settings.set_string(GITHUB_LOGIN, ""))?;
129    Ok(())
130}
131
132/// Submit a saved translation to the Translation Hub, or update its open pull request.
133///
134/// If GitHub rejects the stored token, the user is signed out so the next attempt asks them to sign in again.
135///
136/// # Arguments
137///
138/// * `game_key` - Key of the game the translation belongs to.
139/// * `pack_name` - Name of the pack the translation is for.
140/// * `src_lang` - Source language code.
141/// * `language` - Target language code.
142///
143/// # Errors
144///
145/// Returns an error if the translation can't be loaded, or the submission fails for any reason other than the sign-in.
146pub fn submit(game_key: &str, pack_name: &str, src_lang: &str, language: &str) -> Result<SubmitOutcome> {
147    let Some(token) = token()? else {
148        return Ok(SubmitOutcome::SignInRequired);
149    };
150
151    // Submit exactly what's saved locally, not whatever version of it the hub may have.
152    let local_path = translations_local_path()?;
153    let translation = PackTranslation::load(std::slice::from_ref(&local_path), pack_name, game_key, src_lang, language)?;
154    let content = fs::read_to_string(local_path.join(translation.relative_path(game_key)))?;
155
156    let (hub_owner, hub_name) = hub_repository();
157    let client = GitHubClient::new(&token)?;
158    match translation.submit_to_hub(&client, &hub_owner, &hub_name, game_key, &content) {
159        Ok(result) => Ok(SubmitOutcome::Submitted(result)),
160        Err(RLibError::GitHubUnauthorized) => {
161            sign_out()?;
162            Ok(SubmitOutcome::SignInRequired)
163        },
164        Err(error) => Err(error.into()),
165    }
166}
167
168/// Stored GitHub token, if the user is signed in.
169fn token() -> Result<Option<String>> {
170    match keyring_entry()?.get_password() {
171        Ok(token) => Ok(Some(token)),
172        Err(KeyringError::NoEntry) => Ok(None),
173        Err(error) => Err(keyring_error(error)),
174    }
175}
176
177/// Keyring entry holding the GitHub token.
178fn keyring_entry() -> Result<Entry> {
179    Entry::new(KEYRING_SERVICE, KEYRING_GITHUB_TOKEN).map_err(keyring_error)
180}
181
182/// Explain keyring errors, as they're usually about the system's keyring being unavailable.
183fn keyring_error(error: KeyringError) -> anyhow::Error {
184    anyhow!("Couldn't access the system's keyring, where RPFM stores your GitHub sign-in: {error}")
185}
186
187/// Owner and name of the repository translations are submitted to.
188///
189/// Debug builds can submit to another repository, set in `RPFM_TRANSLATION_HUB` as `owner/name`, to test without touching the real hub.
190fn hub_repository() -> (String, String) {
191    #[cfg(debug_assertions)]
192    if let Ok(value) = std::env::var(HUB_OVERRIDE_VAR) {
193        if let Some((owner, name)) = value.split_once('/') {
194            return (owner.to_owned(), name.to_owned());
195        }
196    }
197
198    (TRANSLATIONS_REPO_OWNER.to_owned(), TRANSLATIONS_REPO_NAME.to_owned())
199}