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