mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Fix Codex OAuth auth being cleared during preserve-mode takeover
When "preserve official auth on switch" is enabled, proxy takeover routes the PROXY_MANAGED placeholder into config.toml's experimental_bearer_token and leaves auth.json (the ChatGPT OAuth login) untouched. Takeover detection only inspected auth.json's OPENAI_API_KEY, so it never recognized this state and returned a false negative, which led downstream paths to clobber the preserved OAuth login. - Detection: is_codex_live_taken_over now also matches a config.toml experimental_bearer_token equal to the placeholder, fixing detect/cleanup/ restore/startup-recovery in one place. - Cleanup: remove the config.toml bearer token only when it equals the placeholder (new remove_codex_experimental_bearer_token_if predicate), so a real third-party key is never stripped. - Write: under preservation, the None-provider takeover path writes only config.toml and keeps auth.json intact, matching the provider path. - Settings: rename the section to "Codex App Enhancements" and reword the description across all four locales. - Add tests covering OAuth preservation on takeover and placeholder-only cleanup.
This commit is contained in:
@@ -739,7 +739,10 @@ fn set_codex_experimental_bearer_token(config_text: &str, token: &str) -> Result
|
|||||||
Ok(doc.to_string())
|
Ok(doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
|
pub fn remove_codex_experimental_bearer_token_if(
|
||||||
|
config_text: &str,
|
||||||
|
predicate: impl Fn(&str) -> bool,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") {
|
if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") {
|
||||||
return Ok(config_text.to_string());
|
return Ok(config_text.to_string());
|
||||||
}
|
}
|
||||||
@@ -755,14 +758,32 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
|
|||||||
.and_then(|table| table.get_mut(provider_id.as_str()))
|
.and_then(|table| table.get_mut(provider_id.as_str()))
|
||||||
.and_then(|item| item.as_table_mut())
|
.and_then(|item| item.as_table_mut())
|
||||||
{
|
{
|
||||||
|
let should_remove = provider_table
|
||||||
|
.get("experimental_bearer_token")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(&predicate);
|
||||||
|
if should_remove {
|
||||||
provider_table.remove("experimental_bearer_token");
|
provider_table.remove("experimental_bearer_token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let should_remove_top_level = doc
|
||||||
|
.get("experimental_bearer_token")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(&predicate);
|
||||||
|
if should_remove_top_level {
|
||||||
doc.as_table_mut().remove("experimental_bearer_token");
|
doc.as_table_mut().remove("experimental_bearer_token");
|
||||||
|
}
|
||||||
Ok(doc.to_string())
|
Ok(doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
|
||||||
|
remove_codex_experimental_bearer_token_if(config_text, |_| true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the current Codex live settings as a `{ auth, config }` object.
|
/// Read the current Codex live settings as a `{ auth, config }` object.
|
||||||
///
|
///
|
||||||
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
|
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
|
||||||
|
|||||||
+190
-12
@@ -1242,22 +1242,19 @@ impl ProxyService {
|
|||||||
.get("config")
|
.get("config")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let codex_provider = self
|
let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;
|
||||||
.get_current_provider_for_app(&AppType::Codex)
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
|
||||||
config_str,
|
config_str,
|
||||||
&proxy_codex_base_url,
|
&proxy_codex_base_url,
|
||||||
codex_provider.as_ref(),
|
Some(&codex_provider),
|
||||||
);
|
);
|
||||||
live_config["config"] = json!(updated_config);
|
live_config["config"] = json!(updated_config);
|
||||||
Self::attach_codex_model_catalog_from_provider(
|
Self::attach_codex_model_catalog_from_provider(
|
||||||
&mut live_config,
|
&mut live_config,
|
||||||
codex_provider.as_ref(),
|
Some(&codex_provider),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;
|
self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?;
|
||||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
@@ -1602,6 +1599,11 @@ impl ProxyService {
|
|||||||
|
|
||||||
if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) {
|
if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) {
|
||||||
let updated = Self::remove_local_toml_base_url(cfg_str);
|
let updated = Self::remove_local_toml_base_url(cfg_str);
|
||||||
|
let updated =
|
||||||
|
crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {
|
||||||
|
token == PROXY_TOKEN_PLACEHOLDER
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?;
|
||||||
config["config"] = json!(updated);
|
config["config"] = json!(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1715,11 +1717,22 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_codex_live_taken_over(config: &Value) -> bool {
|
fn is_codex_live_taken_over(config: &Value) -> bool {
|
||||||
let auth = match config.get("auth").and_then(|v| v.as_object()) {
|
if config
|
||||||
Some(auth) => auth,
|
.get("auth")
|
||||||
None => return false,
|
.and_then(|v| v.as_object())
|
||||||
};
|
.and_then(|auth| auth.get("OPENAI_API_KEY"))
|
||||||
auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
|
.and_then(|v| v.as_str())
|
||||||
|
== Some(PROXY_TOKEN_PLACEHOLDER)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(crate::codex_config::extract_codex_experimental_bearer_token)
|
||||||
|
.as_deref()
|
||||||
|
== Some(PROXY_TOKEN_PLACEHOLDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_gemini_live_taken_over(config: &Value) -> bool {
|
fn is_gemini_live_taken_over(config: &Value) -> bool {
|
||||||
@@ -2055,6 +2068,25 @@ impl ProxyService {
|
|||||||
provider: Option<&Provider>,
|
provider: Option<&Provider>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(provider) = provider else {
|
let Some(provider) = provider else {
|
||||||
|
if crate::settings::preserve_codex_official_auth_on_switch() {
|
||||||
|
if let (Some(auth), Some(config_str)) = (
|
||||||
|
config.get("auth"),
|
||||||
|
config.get("config").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str())
|
||||||
|
== Some(PROXY_TOKEN_PLACEHOLDER)
|
||||||
|
{
|
||||||
|
let live_config = crate::codex_config::prepare_codex_provider_live_config(
|
||||||
|
auth, config_str,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||||
|
crate::codex_config::write_codex_live_config_atomic(Some(&live_config))
|
||||||
|
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return self.write_codex_live_verbatim(config);
|
return self.write_codex_live_verbatim(config);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2645,6 +2677,152 @@ wire_api = "responses"
|
|||||||
.expect("reset settings");
|
.expect("reset settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
crate::settings::update_settings(crate::settings::AppSettings {
|
||||||
|
preserve_codex_official_auth_on_switch: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.expect("enable Codex official auth preservation");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
let oauth_auth = json!({
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"tokens": {
|
||||||
|
"id_token": "oauth-id",
|
||||||
|
"access_token": "oauth-access"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let deepseek_live_config = r#"model_provider = "deepseek"
|
||||||
|
model = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
[model_providers.deepseek]
|
||||||
|
name = "DeepSeek"
|
||||||
|
base_url = "https://api.deepseek.com/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
experimental_bearer_token = "deepseek-key"
|
||||||
|
"#;
|
||||||
|
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))
|
||||||
|
.expect("seed live OAuth auth with DeepSeek config");
|
||||||
|
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"deepseek".to_string(),
|
||||||
|
"DeepSeek".to_string(),
|
||||||
|
json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": "deepseek-key"
|
||||||
|
},
|
||||||
|
"config": r#"model_provider = "deepseek"
|
||||||
|
model = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
[model_providers.deepseek]
|
||||||
|
name = "DeepSeek"
|
||||||
|
base_url = "https://api.deepseek.com/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
"#
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
provider.category = Some("cn_official".to_string());
|
||||||
|
db.save_provider("codex", &provider)
|
||||||
|
.expect("save DeepSeek provider");
|
||||||
|
db.set_current_provider("codex", "deepseek")
|
||||||
|
.expect("set current provider");
|
||||||
|
crate::settings::set_current_provider(&AppType::Codex, Some("deepseek"))
|
||||||
|
.expect("set local current provider");
|
||||||
|
|
||||||
|
service
|
||||||
|
.takeover_live_config_strict(&AppType::Codex)
|
||||||
|
.await
|
||||||
|
.expect("take over Codex live config");
|
||||||
|
|
||||||
|
let live_auth: Value =
|
||||||
|
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||||
|
.expect("read live auth");
|
||||||
|
assert_eq!(
|
||||||
|
live_auth, oauth_auth,
|
||||||
|
"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||||
|
.expect("read live config");
|
||||||
|
assert!(
|
||||||
|
live_config.contains(PROXY_TOKEN_PLACEHOLDER),
|
||||||
|
"takeover placeholder should move into config.toml"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service.detect_takeover_in_live_config_for_app(&AppType::Codex),
|
||||||
|
"Codex takeover detection should recognize config.toml placeholders"
|
||||||
|
);
|
||||||
|
|
||||||
|
crate::settings::update_settings(crate::settings::AppSettings::default())
|
||||||
|
.expect("reset settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db);
|
||||||
|
let oauth_auth = json!({
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"tokens": {
|
||||||
|
"id_token": "oauth-id",
|
||||||
|
"access_token": "oauth-access"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
crate::codex_config::write_codex_live_atomic(
|
||||||
|
&oauth_auth,
|
||||||
|
Some(
|
||||||
|
r#"model_provider = "deepseek"
|
||||||
|
model = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
[model_providers.deepseek]
|
||||||
|
name = "DeepSeek"
|
||||||
|
base_url = "http://127.0.0.1:15721/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
experimental_bearer_token = "PROXY_MANAGED"
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("seed taken-over Codex live config");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service.detect_takeover_in_live_config_for_app(&AppType::Codex),
|
||||||
|
"config.toml placeholder should be detected before cleanup"
|
||||||
|
);
|
||||||
|
|
||||||
|
service
|
||||||
|
.cleanup_codex_takeover_placeholders_in_live()
|
||||||
|
.expect("cleanup Codex takeover placeholders");
|
||||||
|
|
||||||
|
let live_auth: Value =
|
||||||
|
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||||
|
.expect("read live auth");
|
||||||
|
assert_eq!(
|
||||||
|
live_auth, oauth_auth,
|
||||||
|
"cleanup should preserve ChatGPT OAuth auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||||
|
.expect("read live config");
|
||||||
|
assert!(
|
||||||
|
!live_config.contains(PROXY_TOKEN_PLACEHOLDER),
|
||||||
|
"cleanup should remove config.toml proxy bearer placeholder"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!live_config.contains("http://127.0.0.1:15721"),
|
||||||
|
"cleanup should remove local proxy base_url"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {
|
fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {
|
||||||
|
|||||||
@@ -242,11 +242,11 @@ export function SettingsPage({
|
|||||||
handleAutoSave({ skillSyncMethod: method })
|
handleAutoSave({ skillSyncMethod: method })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<WindowSettings
|
<CodexAuthSettings
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onChange={handleAutoSave}
|
onChange={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
<CodexAuthSettings
|
<WindowSettings
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onChange={handleAutoSave}
|
onChange={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -562,9 +562,9 @@
|
|||||||
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
||||||
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
|
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
|
||||||
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
|
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
|
||||||
"codexAuth": "Codex Authentication",
|
"codexAuth": "Codex App Enhancements",
|
||||||
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
|
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
|
||||||
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, third-party Codex switches update only config.toml and keep the ChatGPT login in auth.json. When disabled, auth.json is overwritten with the active provider auth.",
|
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
|
||||||
"appVisibility": {
|
"appVisibility": {
|
||||||
"title": "Homepage Display",
|
"title": "Homepage Display",
|
||||||
"description": "Choose which apps to show on the homepage",
|
"description": "Choose which apps to show on the homepage",
|
||||||
|
|||||||
@@ -562,9 +562,9 @@
|
|||||||
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
||||||
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
|
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
|
||||||
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
|
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
|
||||||
"codexAuth": "Codex 認証",
|
"codexAuth": "Codex アプリ拡張",
|
||||||
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
|
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
|
||||||
"preserveCodexOfficialAuthOnSwitchDescription": "オンにするとサードパーティ Codex への切替では config.toml のみ更新し、auth.json の ChatGPT ログインを保持します。オフにすると auth.json を現在のプロバイダー認証で上書きします。",
|
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
|
||||||
"appVisibility": {
|
"appVisibility": {
|
||||||
"title": "ホームページ表示",
|
"title": "ホームページ表示",
|
||||||
"description": "ホームページに表示するアプリを選択",
|
"description": "ホームページに表示するアプリを選択",
|
||||||
|
|||||||
@@ -562,9 +562,9 @@
|
|||||||
"enableClaudePluginIntegrationDescription": "開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換",
|
"enableClaudePluginIntegrationDescription": "開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換",
|
||||||
"skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認",
|
"skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認",
|
||||||
"skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認",
|
"skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認",
|
||||||
"codexAuth": "Codex 認證",
|
"codexAuth": "Codex 應用增強",
|
||||||
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
|
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
|
||||||
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後切換第三方 Codex 供應商只更新 config.toml,並保留 auth.json 中的 ChatGPT 登入狀態;關閉後會用目前供應商 auth 覆寫 auth.json。",
|
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
|
||||||
"appVisibility": {
|
"appVisibility": {
|
||||||
"title": "主頁面顯示",
|
"title": "主頁面顯示",
|
||||||
"description": "選擇在主頁面顯示的應用程式",
|
"description": "選擇在主頁面顯示的應用程式",
|
||||||
|
|||||||
@@ -562,9 +562,9 @@
|
|||||||
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
||||||
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
|
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
|
||||||
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
|
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
|
||||||
"codexAuth": "Codex 认证",
|
"codexAuth": "Codex 应用增强",
|
||||||
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
|
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
|
||||||
"preserveCodexOfficialAuthOnSwitchDescription": "开启后切换第三方 Codex 供应商只更新 config.toml,并保留 auth.json 中的 ChatGPT 登录态;关闭后会用当前供应商 auth 覆盖 auth.json。",
|
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
|
||||||
"appVisibility": {
|
"appVisibility": {
|
||||||
"title": "主页面显示",
|
"title": "主页面显示",
|
||||||
"description": "选择在主页面显示的应用",
|
"description": "选择在主页面显示的应用",
|
||||||
|
|||||||
Reference in New Issue
Block a user