Implement browser OAuth for ChatGPT subscriptions
Update app branding and OAuth callback handling, remove legacy Samsung theme aliases, prune unavailable ChatGPT models, and delete cost data.
This commit is contained in:
+340
-49
@@ -1,41 +1,55 @@
|
||||
//! ChatGPT subscription OAuth state used by the AI settings page.
|
||||
//! ChatGPT subscription browser OAuth state used by the AI settings page.
|
||||
//!
|
||||
//! Instead of using Rig's device-code flow (which requires users to copy a code),
|
||||
//! this module implements a standard OAuth 2.0 Authorization Code + PKCE flow:
|
||||
//! 1. Open the browser to OpenAI's authorize endpoint
|
||||
//! 2. User approves in browser
|
||||
//! 3. Browser redirects back to `galaxy://chatgpt/oauth2callback?code=...&state=...`
|
||||
//! 4. We exchange the code for tokens and write them to Rig's auth file
|
||||
|
||||
use async_channel::unbounded;
|
||||
use galaxy_agent_rig::{ChatGPTDeviceCode, ChatGPTSubscriptionClient};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/api/accounts/authorize";
|
||||
const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const CHATGPT_SCOPES: &str = "openid profile email offline_access";
|
||||
|
||||
/// Current state of the local ChatGPT subscription connection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ChatGPTAuthState {
|
||||
NotConnected,
|
||||
Connecting,
|
||||
AwaitingDeviceCode {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
},
|
||||
AwaitingBrowser,
|
||||
ExchangingToken,
|
||||
Connected,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
enum ChatGPTAuthEvent {
|
||||
DeviceCode(ChatGPTDeviceCode),
|
||||
Completed(Result<(), String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ChatGPTAuthModelEvent {
|
||||
StateChanged,
|
||||
}
|
||||
|
||||
/// Coordinates Rig's device authorization flow with Galaxy UI.
|
||||
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
|
||||
pub(crate) struct ChatGPTAuthModel {
|
||||
state: ChatGPTAuthState,
|
||||
/// PKCE code verifier stored between authorize and callback.
|
||||
pending_code_verifier: Option<String>,
|
||||
/// CSRF state token stored between authorize and callback.
|
||||
pending_state: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatGPTAuthModel {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
state: ChatGPTAuthState::NotConnected,
|
||||
pending_code_verifier: None,
|
||||
pending_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,55 +57,116 @@ impl ChatGPTAuthModel {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Attempts to connect using existing Codex credentials, falling back to browser OAuth.
|
||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if matches!(
|
||||
self.state,
|
||||
ChatGPTAuthState::Connecting | ChatGPTAuthState::AwaitingDeviceCode { .. }
|
||||
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = ChatGPTAuthState::Connecting;
|
||||
// Try to import credentials from ~/.codex/auth.json first.
|
||||
if let Ok(()) = import_codex_credentials() {
|
||||
self.state = ChatGPTAuthState::Connected;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing credentials — start the browser OAuth flow.
|
||||
let code_verifier = generate_random_string(64);
|
||||
let code_challenge = compute_code_challenge(&code_verifier);
|
||||
let state = generate_random_string(32);
|
||||
let redirect_uri = chatgpt_redirect_uri();
|
||||
|
||||
let authorize_url = format!(
|
||||
"{CHATGPT_AUTHORIZE_URL}?\
|
||||
client_id={CHATGPT_CLIENT_ID}\
|
||||
&response_type=code\
|
||||
&redirect_uri={redirect_uri}\
|
||||
&code_challenge={code_challenge}\
|
||||
&code_challenge_method=S256\
|
||||
&state={state}\
|
||||
&scope={}",
|
||||
urlencoding::encode(CHATGPT_SCOPES),
|
||||
);
|
||||
|
||||
self.pending_code_verifier = Some(code_verifier);
|
||||
self.pending_state = Some(state);
|
||||
self.state = ChatGPTAuthState::AwaitingBrowser;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
|
||||
let (event_tx, event_rx) = unbounded();
|
||||
let device_code_tx = event_tx.clone();
|
||||
let _ = ctx.spawn_stream_local(
|
||||
event_rx,
|
||||
|model, event, ctx| {
|
||||
match event {
|
||||
ChatGPTAuthEvent::DeviceCode(code) => {
|
||||
model.state = ChatGPTAuthState::AwaitingDeviceCode {
|
||||
verification_uri: code.verification_uri,
|
||||
user_code: code.user_code,
|
||||
};
|
||||
}
|
||||
ChatGPTAuthEvent::Completed(result) => {
|
||||
model.state = match result {
|
||||
Ok(()) => ChatGPTAuthState::Connected,
|
||||
Err(error) => ChatGPTAuthState::Failed(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
ctx.open_url(&authorize_url);
|
||||
}
|
||||
|
||||
/// Called when the OS routes back `galaxy://chatgpt/oauth2callback?code=...&state=...`
|
||||
pub(crate) fn handle_oauth_callback(&mut self, url: &Url, ctx: &mut ModelContext<Self>) {
|
||||
let Some(expected_state) = self.pending_state.take() else {
|
||||
self.fail(
|
||||
"Received OAuth callback but no authorization was in progress.",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(code_verifier) = self.pending_code_verifier.take() else {
|
||||
self.fail("Received OAuth callback but code verifier is missing.", ctx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Extract query parameters
|
||||
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
||||
|
||||
// Check for error response from the authorization server
|
||||
if let Some(error) = params.get("error") {
|
||||
let description = params
|
||||
.get("error_description")
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| error.to_string());
|
||||
self.fail(&format!("ChatGPT authorization denied: {description}"), ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(code) = params.get("code") else {
|
||||
self.fail("OAuth callback missing authorization code.", ctx);
|
||||
return;
|
||||
};
|
||||
let code = code.to_string();
|
||||
|
||||
let Some(state) = params.get("state") else {
|
||||
self.fail("OAuth callback missing state parameter.", ctx);
|
||||
return;
|
||||
};
|
||||
|
||||
if *state != expected_state {
|
||||
self.fail("OAuth callback state mismatch (possible CSRF).", ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = ChatGPTAuthState::ExchangingToken;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
|
||||
let redirect_uri = chatgpt_redirect_uri();
|
||||
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let result =
|
||||
match ChatGPTSubscriptionClient::with_device_code_handler(move |code| {
|
||||
let _ = device_code_tx.try_send(ChatGPTAuthEvent::DeviceCode(code));
|
||||
}) {
|
||||
Ok(client) => client.authorize().await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let _ = event_tx.send(ChatGPTAuthEvent::Completed(result)).await;
|
||||
async move { exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await },
|
||||
|model, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
model.state = ChatGPTAuthState::Connected;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
}
|
||||
Err(error) => {
|
||||
model.fail(&error, ctx);
|
||||
}
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
}
|
||||
|
||||
fn fail(&mut self, message: &str, ctx: &mut ModelContext<Self>) {
|
||||
self.state = ChatGPTAuthState::Failed(message.to_string());
|
||||
self.pending_code_verifier = None;
|
||||
self.pending_state = None;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ChatGPTAuthModel {
|
||||
@@ -99,3 +174,219 @@ impl Entity for ChatGPTAuthModel {
|
||||
}
|
||||
|
||||
impl SingletonEntity for ChatGPTAuthModel {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn chatgpt_redirect_uri() -> String {
|
||||
format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme())
|
||||
}
|
||||
|
||||
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file.
|
||||
/// Returns `Ok(())` if credentials were found and successfully imported.
|
||||
fn import_codex_credentials() -> Result<(), String> {
|
||||
let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?;
|
||||
let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?;
|
||||
|
||||
let tokens = doc.get("tokens").ok_or("No tokens object")?;
|
||||
let access_token = tokens
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or("No access_token")?;
|
||||
|
||||
let expires_at = extract_expiration_timestamp(access_token);
|
||||
|
||||
// If the token is expired, don't import stale credentials.
|
||||
if let Some(exp) = expires_at {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if now >= exp - 60 {
|
||||
return Err("Codex access token is expired".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let refresh_token = tokens
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned);
|
||||
let id_token = tokens
|
||||
.get("id_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned);
|
||||
let account_id = tokens
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| extract_account_id(id_token.as_deref()))
|
||||
.or_else(|| extract_account_id(Some(access_token)));
|
||||
|
||||
let record = AuthRecord {
|
||||
access_token: Some(access_token.to_owned()),
|
||||
refresh_token,
|
||||
id_token,
|
||||
expires_at,
|
||||
account_id,
|
||||
};
|
||||
|
||||
write_auth_file(&record)
|
||||
}
|
||||
|
||||
fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
||||
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
||||
}
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
||||
}
|
||||
|
||||
fn generate_random_string(len: usize) -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..len)
|
||||
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compute_code_challenge(verifier: &str) -> String {
|
||||
let hash = Sha256::digest(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hash)
|
||||
}
|
||||
|
||||
/// Exchange the authorization code for tokens and write them to Rig's auth file.
|
||||
async fn exchange_code_for_tokens(
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let form = [
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", CHATGPT_CLIENT_ID),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", code_verifier),
|
||||
];
|
||||
|
||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||
.extend_pairs(form)
|
||||
.finish();
|
||||
|
||||
let response = client
|
||||
.post(CHATGPT_TOKEN_URL)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Token exchange request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Token exchange failed: {status} {body}"));
|
||||
}
|
||||
|
||||
let token_response: TokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse token response: {e}"))?;
|
||||
|
||||
let access_token = token_response.access_token;
|
||||
let refresh_token = token_response.refresh_token;
|
||||
let id_token = token_response.id_token;
|
||||
|
||||
let expires_at = extract_expiration_timestamp(&access_token);
|
||||
let account_id =
|
||||
extract_account_id(id_token.as_deref()).or_else(|| extract_account_id(Some(&access_token)));
|
||||
|
||||
let auth_record = AuthRecord {
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
id_token,
|
||||
expires_at,
|
||||
account_id,
|
||||
};
|
||||
|
||||
write_auth_file(&auth_record)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
||||
let path = auth_file_path().ok_or("Cannot determine auth file path")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create auth directory: {e}"))?;
|
||||
}
|
||||
let json =
|
||||
serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_file_path() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var_os("APPDATA").map(|d| {
|
||||
std::path::PathBuf::from(d)
|
||||
.join("chatgpt")
|
||||
.join("auth.json")
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
|
||||
})
|
||||
.map(|d| d.join("chatgpt").join("auth.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_expiration_timestamp(token: &str) -> Option<i64> {
|
||||
decode_jwt_claims(token)
|
||||
.get("exp")
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64)))
|
||||
}
|
||||
|
||||
fn extract_account_id(token: Option<&str>) -> Option<String> {
|
||||
let claims = decode_jwt_claims(token?);
|
||||
claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|map| map.get("chatgpt_account_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn decode_jwt_claims(token: &str) -> serde_json::Value {
|
||||
let payload = token.split('.').nth(1).unwrap_or_default();
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload.as_bytes());
|
||||
decoded
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AuthRecord {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_at: Option<i64>,
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
+4
-4
@@ -12,7 +12,7 @@ fn main() -> Result<()> {
|
||||
let mut state = ChannelState::new(
|
||||
Channel::Oss,
|
||||
ChannelConfig {
|
||||
app_id: AppId::new("com", "samsung", "Galaxy"),
|
||||
app_id: AppId::new("com", "galaxy", "Galaxy"),
|
||||
logfile_name: "galaxy.log".into(),
|
||||
server_config: WarpServerConfig::disabled(),
|
||||
oz_config: OzConfig::production(),
|
||||
@@ -51,7 +51,7 @@ embed_plist::embed_info_plist_bytes!(r#"
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>galaxy-oss</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>samsung.galaxy.GalaxyOss</string>
|
||||
<string>com.galaxy.GalaxyOss</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
@@ -67,9 +67,9 @@ embed_plist::embed_info_plist_bytes!(r#"
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array><dict><key>CFBundleURLName</key><string>Galaxy</string><key>CFBundleURLSchemes</key><array><string>galaxyai</string></array></dict></array>
|
||||
<array><dict><key>CFBundleURLName</key><string>Galaxy</string><key>CFBundleURLSchemes</key><array><string>galaxy</string></array></dict></array>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Samsung Electronics Co., Ltd.</string>
|
||||
<string>© 2026, Galaxy Project</string>
|
||||
<key>NSDockTilePlugIn</key>
|
||||
<string>GalaxyDockTilePlugin.docktileplugin</string>
|
||||
</dict>
|
||||
|
||||
@@ -1071,34 +1071,6 @@ fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex",
|
||||
"GPT-5.3 Codex",
|
||||
vec!["low", "medium", "high", "xhigh"],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex-spark",
|
||||
"GPT-5.3 Codex Spark",
|
||||
vec![],
|
||||
128_000,
|
||||
Some(121_600),
|
||||
),
|
||||
(
|
||||
"gpt-5.3-instant",
|
||||
"GPT-5.3 Instant",
|
||||
vec![],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-chat-latest",
|
||||
"GPT-5.3 Chat Latest",
|
||||
vec![],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(
|
||||
|
||||
@@ -58,7 +58,7 @@ use crate::ai::blocklist::agent_view::agent_input_footer::editor::{
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState};
|
||||
use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent};
|
||||
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::execution_profiles::profiles::{
|
||||
@@ -3735,43 +3735,11 @@ impl TypedActionView for AISettingsPageView {
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::OpenChatGPTDevicePage =>
|
||||
{
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let ChatGPTAuthState::AwaitingDeviceCode {
|
||||
verification_uri, ..
|
||||
} = ChatGPTAuthModel::as_ref(ctx).state()
|
||||
{
|
||||
ctx.open_url(verification_uri);
|
||||
}
|
||||
AISettingsPageAction::OpenChatGPTDevicePage => {
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
AISettingsPageAction::CopyChatGPTDeviceCode => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let user_code = match ChatGPTAuthModel::as_ref(ctx).state() {
|
||||
ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } => {
|
||||
Some(user_code.clone())
|
||||
}
|
||||
ChatGPTAuthState::NotConnected
|
||||
| ChatGPTAuthState::Connecting
|
||||
| ChatGPTAuthState::Connected
|
||||
| ChatGPTAuthState::Failed(_) => None,
|
||||
};
|
||||
#[cfg(target_family = "wasm")]
|
||||
let user_code: Option<String> = None;
|
||||
if let Some(user_code) = user_code {
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(user_code));
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
crate::view_components::DismissibleToast::success(
|
||||
"ChatGPT device code copied.".to_string(),
|
||||
),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
AISettingsPageAction::ToggleAcpEnabled => {
|
||||
if cfg!(unix) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
|
||||
@@ -225,8 +224,6 @@ pub struct ProviderSetupModalBody {
|
||||
acp_command_editor: ViewHandle<EditorView>,
|
||||
acp_args_editor: ViewHandle<EditorView>,
|
||||
chatgpt_connect_mouse_state: MouseStateHandle,
|
||||
chatgpt_open_mouse_state: MouseStateHandle,
|
||||
chatgpt_copy_mouse_state: MouseStateHandle,
|
||||
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
|
||||
bedrock_cross_region_toggle: SwitchStateHandle,
|
||||
bedrock_auto_login_toggle: SwitchStateHandle,
|
||||
@@ -460,8 +457,6 @@ impl ProviderSetupModalBody {
|
||||
acp_command_editor,
|
||||
acp_args_editor,
|
||||
chatgpt_connect_mouse_state: MouseStateHandle::default(),
|
||||
chatgpt_open_mouse_state: MouseStateHandle::default(),
|
||||
chatgpt_copy_mouse_state: MouseStateHandle::default(),
|
||||
bedrock_auth_buttons,
|
||||
bedrock_cross_region_toggle: SwitchStateHandle::default(),
|
||||
bedrock_auto_login_toggle: SwitchStateHandle::default(),
|
||||
@@ -1129,10 +1124,8 @@ impl ProviderSetupModalBody {
|
||||
let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")];
|
||||
let description = match &state {
|
||||
ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.",
|
||||
ChatGPTAuthState::Connecting => "Waiting for ChatGPT authorization to start...",
|
||||
ChatGPTAuthState::AwaitingDeviceCode { .. } => {
|
||||
"Enter the device code in the ChatGPT sign-in page."
|
||||
}
|
||||
ChatGPTAuthState::AwaitingBrowser => "Waiting for ChatGPT sign-in in your browser...",
|
||||
ChatGPTAuthState::ExchangingToken => "Completing sign-in...",
|
||||
ChatGPTAuthState::Connected => "ChatGPT subscription connected.",
|
||||
ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.",
|
||||
};
|
||||
@@ -1151,79 +1144,9 @@ impl ProviderSetupModalBody {
|
||||
);
|
||||
}
|
||||
|
||||
if let ChatGPTAuthState::AwaitingDeviceCode {
|
||||
verification_uri,
|
||||
user_code,
|
||||
} = &state
|
||||
{
|
||||
children.push(
|
||||
Container::new(
|
||||
FormattedTextElement::from_str(
|
||||
user_code.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_weight(Weight::Bold)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding(Padding::uniform(12.))
|
||||
.with_background(appearance.theme().surface_1())
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().accent()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish(),
|
||||
);
|
||||
let buttons = Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.chatgpt_open_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Open sign-in page".to_owned())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::OpenChatGPTDevicePage,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.chatgpt_copy_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Copy code".to_owned())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
children.push(buttons);
|
||||
children.push(
|
||||
Text::new(
|
||||
verification_uri.clone(),
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
if matches!(
|
||||
state,
|
||||
ChatGPTAuthState::Connected | ChatGPTAuthState::Connecting
|
||||
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_)
|
||||
) {
|
||||
children.push(
|
||||
appearance
|
||||
@@ -2096,25 +2019,10 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||
}
|
||||
ProviderSetupModalBodyAction::OpenChatGPTDevicePage => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone();
|
||||
let verification_uri = match auth_state {
|
||||
ChatGPTAuthState::AwaitingDeviceCode {
|
||||
verification_uri, ..
|
||||
} => Some(verification_uri),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(verification_uri) = verification_uri {
|
||||
ctx.open_url(&verification_uri);
|
||||
}
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone();
|
||||
if let ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } = auth_state {
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(user_code));
|
||||
}
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
ProviderSetupModalBodyAction::SelectBedrockAuth(method) => {
|
||||
self.draft_bedrock.auth_method = *method;
|
||||
|
||||
@@ -47,10 +47,8 @@ pub enum ThemeKind {
|
||||
ReceivedReferralReward,
|
||||
#[schemars(description = "Adeberry")]
|
||||
Adeberry,
|
||||
#[serde(alias = "SamsungDark")]
|
||||
#[schemars(description = "Galaxy Dark")]
|
||||
GalaxyDark,
|
||||
#[serde(alias = "SamsungLight")]
|
||||
#[schemars(description = "Galaxy Day")]
|
||||
GalaxyDay,
|
||||
#[schemars(description = "Phenomenon")]
|
||||
|
||||
@@ -27,18 +27,6 @@ fn assert_custom_theme_is_not_syncable(custom_theme: CustomTheme) {
|
||||
assert!(!ThemeKind::Custom(custom_theme).is_custom_theme_reference_syncable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_samsung_theme_names_deserialize_as_galaxy_themes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ThemeKind>(r#""SamsungDark""#).unwrap(),
|
||||
ThemeKind::GalaxyDark
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ThemeKind>(r#""SamsungLight""#).unwrap(),
|
||||
ThemeKind::GalaxyDay
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galaxy_theme_names_serialize_without_legacy_branding() {
|
||||
assert_eq!(
|
||||
@@ -51,18 +39,6 @@ fn galaxy_theme_names_serialize_without_legacy_branding() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_samsung_theme_settings_values_deserialize_as_galaxy_themes() {
|
||||
assert_eq!(
|
||||
ThemeKind::from_file_value(&serde_json::json!("samsung_dark")),
|
||||
Some(ThemeKind::GalaxyDark)
|
||||
);
|
||||
assert_eq!(
|
||||
ThemeKind::from_file_value(&serde_json::json!("samsung_light")),
|
||||
Some(ThemeKind::GalaxyDay)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galaxy_theme_settings_values_serialize_without_legacy_branding() {
|
||||
assert_eq!(
|
||||
|
||||
+13
-1
@@ -112,6 +112,8 @@ pub enum UriHost {
|
||||
TabConfig,
|
||||
/// Focuses a specific terminal pane by its persistent session UUID.
|
||||
Session,
|
||||
/// Handles OAuth callbacks for ChatGPT subscription authorization.
|
||||
ChatGPT,
|
||||
}
|
||||
|
||||
impl FromStr for UriHost {
|
||||
@@ -135,6 +137,7 @@ impl FromStr for UriHost {
|
||||
"linear" => Ok(Self::Linear),
|
||||
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
|
||||
"session" => Ok(Self::Session),
|
||||
"chatgpt" => Ok(Self::ChatGPT),
|
||||
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
|
||||
}
|
||||
}
|
||||
@@ -570,6 +573,13 @@ impl UriHost {
|
||||
log::warn!("session deep link could not find pane with given UUID");
|
||||
}
|
||||
}
|
||||
UriHost::ChatGPT => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
crate::ai::chatgpt_auth::ChatGPTAuthModel::handle(ctx)
|
||||
.update(ctx, |model, ctx| model.handle_oauth_callback(url, ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +605,7 @@ impl UriHost {
|
||||
// Handler picks the window itself based on `?new_window=true`.
|
||||
Self::TabConfig => W::Nothing,
|
||||
Self::Session => W::Nothing,
|
||||
Self::ChatGPT => W::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1669,7 +1680,8 @@ fn validate_custom_uri(url: &Url) -> Result<UriHost> {
|
||||
| UriHost::Codex
|
||||
| UriHost::Linear
|
||||
| UriHost::TabConfig
|
||||
| UriHost::Session => true,
|
||||
| UriHost::Session
|
||||
| UriHost::ChatGPT => true,
|
||||
// Auth and Home only allow the desktop redirect path
|
||||
UriHost::Auth | UriHost::Home => false,
|
||||
};
|
||||
|
||||
@@ -171,7 +171,7 @@ impl View for WasmNUXDialog {
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span("Galaxy is a clone of Warp built for Samsung to use with Bedrock. Get the best features of Warp with the security provided by Bedrock!")
|
||||
.span("Galaxy is an AI-powered terminal. Get the best features with the security provided by your own cloud infrastructure!")
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Thin),
|
||||
font_color: Some(
|
||||
|
||||
Reference in New Issue
Block a user