mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(startup): enforce keychain consent before credential access
Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes. Refs #17
This commit is contained in:
+10
-38
@@ -45,18 +45,17 @@ struct LegacyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
|
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
|
||||||
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable(), false)
|
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
|
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
|
||||||
init_at_path_internal(app_data_dir, false, false)
|
init_at_path_internal(app_data_dir, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_at_path_internal(
|
fn init_at_path_internal(
|
||||||
app_data_dir: &Path,
|
app_data_dir: &Path,
|
||||||
portable: bool,
|
portable: bool,
|
||||||
migrate_legacy_keychain: bool,
|
|
||||||
) -> Result<DbState, String> {
|
) -> Result<DbState, String> {
|
||||||
fs::create_dir_all(app_data_dir)
|
fs::create_dir_all(app_data_dir)
|
||||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
||||||
@@ -78,12 +77,7 @@ fn init_at_path_internal(
|
|||||||
}
|
}
|
||||||
migrate_schema(&mut connection, version)?;
|
migrate_schema(&mut connection, version)?;
|
||||||
|
|
||||||
import_legacy_data(
|
import_legacy_data(&mut connection, app_data_dir, portable)?;
|
||||||
&mut connection,
|
|
||||||
app_data_dir,
|
|
||||||
portable,
|
|
||||||
migrate_legacy_keychain,
|
|
||||||
)?;
|
|
||||||
if portable {
|
if portable {
|
||||||
sanitize_persisted_downloads(&mut connection)?;
|
sanitize_persisted_downloads(&mut connection)?;
|
||||||
}
|
}
|
||||||
@@ -188,7 +182,6 @@ fn import_legacy_data(
|
|||||||
connection: &mut Connection,
|
connection: &mut Connection,
|
||||||
app_data_dir: &Path,
|
app_data_dir: &Path,
|
||||||
portable: bool,
|
portable: bool,
|
||||||
migrate_keychain: bool,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let legacy_app_dir = app_data_dir
|
let legacy_app_dir = app_data_dir
|
||||||
.parent()
|
.parent()
|
||||||
@@ -226,31 +219,10 @@ fn import_legacy_data(
|
|||||||
let mut pending_pairing_token = None;
|
let mut pending_pairing_token = None;
|
||||||
if !portable {
|
if !portable {
|
||||||
if let Some(token) = legacy.pairing_token.take() {
|
if let Some(token) = legacy.pairing_token.take() {
|
||||||
let migrated = if migrate_keychain {
|
// Legacy migration is deliberately deferred until the
|
||||||
let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
|
// explicit frontend consent action. Database initialization
|
||||||
.ok()
|
// must never touch the OS credential store.
|
||||||
.is_some_and(|value| !value.trim().is_empty());
|
pending_pairing_token = Some(token);
|
||||||
if !keychain_has_token {
|
|
||||||
if let Err(error) =
|
|
||||||
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token)
|
|
||||||
{
|
|
||||||
log::warn!(
|
|
||||||
"Legacy pairing token could not be migrated to the credential store; it will remain pending in the current database: {}",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
if !migrated {
|
|
||||||
pending_pairing_token = Some(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if portable {
|
if portable {
|
||||||
@@ -1738,7 +1710,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drop(connection);
|
drop(connection);
|
||||||
|
|
||||||
let state = init_at_path_internal(temp.path(), true, true).unwrap();
|
let state = init_at_path_internal(temp.path(), true).unwrap();
|
||||||
let connection = state.lock().unwrap();
|
let connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
@@ -1828,7 +1800,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
||||||
|
|
||||||
let state = init_at_path_internal(¤t, true, true).unwrap();
|
let state = init_at_path_internal(¤t, true).unwrap();
|
||||||
let connection = state.lock().unwrap();
|
let connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
@@ -2104,7 +2076,7 @@ mod tests {
|
|||||||
drop(connection);
|
drop(connection);
|
||||||
drop(state);
|
drop(state);
|
||||||
|
|
||||||
let state = init_at_path_internal(temp.path(), true, true).unwrap();
|
let state = init_at_path_internal(temp.path(), true).unwrap();
|
||||||
let connection = state.lock().unwrap();
|
let connection = state.lock().unwrap();
|
||||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
|
|||||||
@@ -2859,6 +2859,12 @@ pub enum TaskHandle {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub download_coordinator: download::DownloadCoordinator,
|
pub download_coordinator: download::DownloadCoordinator,
|
||||||
pub storage_layout: crate::storage::StorageLayout,
|
pub storage_layout: crate::storage::StorageLayout,
|
||||||
|
/// Credential-store access is denied until the frontend has completed the
|
||||||
|
/// startup consent decision for this process. This is intentionally
|
||||||
|
/// process-local: a new binary must pass through the consent barrier
|
||||||
|
/// again before any stale or early IPC caller can reach the OS store.
|
||||||
|
pub keychain_access_authorized: Arc<AtomicBool>,
|
||||||
|
pub keychain_grant_in_progress: Arc<AtomicBool>,
|
||||||
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
||||||
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
||||||
pub extension_acks: extension_server::SharedExtensionAcks,
|
pub extension_acks: extension_server::SharedExtensionAcks,
|
||||||
@@ -2872,6 +2878,38 @@ pub struct AppState {
|
|||||||
pub queue_manager: Arc<queue::QueueManager>,
|
pub queue_manager: Arc<queue::QueueManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str =
|
||||||
|
"Credential-store access requires explicit user consent";
|
||||||
|
const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str =
|
||||||
|
"Credential-store access is already being requested";
|
||||||
|
|
||||||
|
fn require_keychain_access(state: &AppState) -> Result<(), String> {
|
||||||
|
if state
|
||||||
|
.keychain_access_authorized
|
||||||
|
.load(Ordering::Acquire)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(KEYCHAIN_CONSENT_REQUIRED_ERROR.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeychainGrantGuard(Arc<AtomicBool>);
|
||||||
|
|
||||||
|
impl Drop for KeychainGrantGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_keychain_grant(state: &AppState) -> Result<KeychainGrantGuard, String> {
|
||||||
|
state
|
||||||
|
.keychain_grant_in_progress
|
||||||
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||||
|
.map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress)))
|
||||||
|
.map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, TS)]
|
#[derive(Clone, Serialize, TS)]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct DownloadProgressEvent {
|
pub struct DownloadProgressEvent {
|
||||||
@@ -5676,6 +5714,7 @@ fn set_keychain_password(
|
|||||||
crate::db::save_pairing_token_to_settings(&connection, &password, true)?;
|
crate::db::save_pairing_token_to_settings(&connection, &password, true)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
require_keychain_access(&state)?;
|
||||||
crate::db::set_keychain_password(&id, &password)
|
crate::db::set_keychain_password(&id, &password)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5689,6 +5728,7 @@ fn get_keychain_password(
|
|||||||
{
|
{
|
||||||
return Err("portable pairing token is stored in portable settings".to_string());
|
return Err("portable pairing token is stored in portable settings".to_string());
|
||||||
}
|
}
|
||||||
|
require_keychain_access(&state)?;
|
||||||
crate::db::get_keychain_password(&id)
|
crate::db::get_keychain_password(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5702,17 +5742,20 @@ fn delete_keychain_password(
|
|||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
require_keychain_access(&state)?;
|
||||||
crate::db::delete_keychain_password(&id)
|
crate::db::delete_keychain_password(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn save_site_login(
|
fn save_site_login(
|
||||||
database: tauri::State<'_, crate::db::DbState>,
|
database: tauri::State<'_, crate::db::DbState>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
url_pattern: String,
|
url_pattern: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
require_keychain_access(&state)?;
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password)
|
crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password)
|
||||||
}
|
}
|
||||||
@@ -5720,12 +5763,28 @@ fn save_site_login(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn delete_site_login(
|
fn delete_site_login(
|
||||||
database: tauri::State<'_, crate::db::DbState>,
|
database: tauri::State<'_, crate::db::DbState>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
require_keychain_access(&state)?;
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
crate::db::delete_site_login(&connection, &id)
|
crate::db::delete_site_login(&connection, &id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Arm credential-store access after the frontend has completed its startup
|
||||||
|
/// decision. The process-local gate prevents any stale or early IPC caller
|
||||||
|
/// from producing a native OS prompt before the explanation is visible.
|
||||||
|
#[tauri::command]
|
||||||
|
fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
|
if state.storage_layout.is_portable() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.keychain_access_authorized
|
||||||
|
.store(true, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -5761,6 +5820,7 @@ fn hydrate_extension_pairing_token(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_keychain_access(&app_state)?;
|
||||||
let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err();
|
let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err();
|
||||||
let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID)
|
let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -5821,6 +5881,7 @@ fn regenerate_pairing_token(
|
|||||||
if app_state.storage_layout.is_portable() {
|
if app_state.storage_layout.is_portable() {
|
||||||
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
|
crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
|
||||||
} else {
|
} else {
|
||||||
|
require_keychain_access(&app_state)?;
|
||||||
if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
|
if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
|
||||||
let token = app_state
|
let token = app_state
|
||||||
.extension_pairing_token
|
.extension_pairing_token
|
||||||
@@ -5868,6 +5929,7 @@ fn grant_keychain_access(
|
|||||||
database: tauri::State<'_, crate::db::DbState>,
|
database: tauri::State<'_, crate::db::DbState>,
|
||||||
app_state: tauri::State<'_, AppState>,
|
app_state: tauri::State<'_, AppState>,
|
||||||
) -> Result<PairingTokenHydration, String> {
|
) -> Result<PairingTokenHydration, String> {
|
||||||
|
let _grant_guard = begin_keychain_grant(&app_state)?;
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
|
|
||||||
if app_state.storage_layout.is_portable() {
|
if app_state.storage_layout.is_portable() {
|
||||||
@@ -5882,6 +5944,9 @@ fn grant_keychain_access(
|
|||||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||||
*pairing_token = token.clone();
|
*pairing_token = token.clone();
|
||||||
}
|
}
|
||||||
|
app_state
|
||||||
|
.keychain_access_authorized
|
||||||
|
.store(true, Ordering::Release);
|
||||||
return Ok(PairingTokenHydration {
|
return Ok(PairingTokenHydration {
|
||||||
token,
|
token,
|
||||||
token_changed: false,
|
token_changed: false,
|
||||||
@@ -5933,6 +5998,9 @@ fn grant_keychain_access(
|
|||||||
*pairing_token = token.clone();
|
*pairing_token = token.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
app_state
|
||||||
|
.keychain_access_authorized
|
||||||
|
.store(true, Ordering::Release);
|
||||||
Ok(PairingTokenHydration {
|
Ok(PairingTokenHydration {
|
||||||
token,
|
token,
|
||||||
token_changed: false,
|
token_changed: false,
|
||||||
@@ -8442,6 +8510,8 @@ pub fn run() {
|
|||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
||||||
storage_layout,
|
storage_layout,
|
||||||
|
keychain_access_authorized: Arc::new(AtomicBool::new(false)),
|
||||||
|
keychain_grant_in_progress: Arc::new(AtomicBool::new(false)),
|
||||||
extension_pairing_token,
|
extension_pairing_token,
|
||||||
extension_frontend_ready,
|
extension_frontend_ready,
|
||||||
extension_acks,
|
extension_acks,
|
||||||
@@ -8971,6 +9041,7 @@ pub fn run() {
|
|||||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||||
save_site_login, delete_site_login,
|
save_site_login, delete_site_login,
|
||||||
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
|
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
|
||||||
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download,
|
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||||
|
|||||||
@@ -8,17 +8,9 @@
|
|||||||
"height": 760,
|
"height": 760,
|
||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"titleBarStyle": "Overlay",
|
|
||||||
"trafficLightPosition": {
|
|
||||||
"x": 17,
|
|
||||||
"y": 28
|
|
||||||
},
|
|
||||||
"hiddenTitle": true,
|
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"windowEffects": {
|
"decorations": false,
|
||||||
"effects": ["sidebar"],
|
"shadow": false
|
||||||
"state": "active"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,10 +10,7 @@
|
|||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"windowEffects": {
|
"shadow": false
|
||||||
"effects": ["mica"],
|
|
||||||
"state": "active"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ function App() {
|
|||||||
settings.setShowKeychainModal(true);
|
settings.setShowKeychainModal(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// The backend keeps credential-store access disabled for every new
|
||||||
|
// process. Arm it only after the persisted startup decision has
|
||||||
|
// confirmed that this build was already approved; the hydrate call
|
||||||
|
// below is then the first operation allowed to touch the OS store.
|
||||||
|
await invoke('authorize_keychain_access');
|
||||||
|
if (!active) return;
|
||||||
changed = await settings.hydratePairingToken(isStartupActive);
|
changed = await settings.hydratePairingToken(isStartupActive);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const currentSettings = useSettingsStore.getState();
|
const currentSettings = useSettingsStore.getState();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { getPlatformInfo } from '../utils/platform';
|
|||||||
import { isTransferLocked } from '../utils/downloadActions';
|
import { isTransferLocked } from '../utils/downloadActions';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { localePluralVariant } from '../i18n/locales';
|
import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales';
|
||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
@@ -116,6 +116,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
|
|||||||
|
|
||||||
export const AddDownloadsModal = () => {
|
export const AddDownloadsModal = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const {
|
const {
|
||||||
isAddModalOpen,
|
isAddModalOpen,
|
||||||
@@ -1247,7 +1248,9 @@ export const AddDownloadsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
|
className={`add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none ${
|
||||||
|
isRtl ? 'add-download-links-input--rtl' : ''
|
||||||
|
}`}
|
||||||
placeholder={t($ => $.addDownloads.pastePlaceholder)}
|
placeholder={t($ => $.addDownloads.pastePlaceholder)}
|
||||||
value={urls}
|
value={urls}
|
||||||
onChange={(e) => setUrls(e.target.value)}
|
onChange={(e) => setUrls(e.target.value)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||||
@@ -20,16 +20,23 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const [isGranting, setIsGranting] = useState(false);
|
const [isGranting, setIsGranting] = useState(false);
|
||||||
|
const [grantRequestPending, setGrantRequestPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showKeychainModal || isGranting) return;
|
if (!showKeychainModal || isGranting || grantRequestPending) return;
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
|
if (event.key !== 'Escape') return;
|
||||||
|
if (consentVersion.trim()) {
|
||||||
|
dismissKeychainPrompt(consentVersion);
|
||||||
|
} else {
|
||||||
|
useSettingsStore.getState().setShowKeychainModal(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
|
||||||
|
|
||||||
if (!showKeychainModal) {
|
if (!showKeychainModal) {
|
||||||
return null;
|
return null;
|
||||||
@@ -56,6 +63,10 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
: t($ => $.keychain.grantLabelDefault);
|
: t($ => $.keychain.grantLabelDefault);
|
||||||
|
|
||||||
const handleGrant = async () => {
|
const handleGrant = async () => {
|
||||||
|
// A native credential-store call cannot be cancelled from the webview.
|
||||||
|
// Keep the request identity until it settles so a UI timeout cannot
|
||||||
|
// launch a second OS prompt while the first one is still outstanding.
|
||||||
|
if (grantRequestRef.current) return;
|
||||||
setIsGranting(true);
|
setIsGranting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -79,9 +90,21 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const grantRequest = invoke('grant_keychain_access');
|
const grantRequest = invoke('grant_keychain_access');
|
||||||
|
grantRequestRef.current = grantRequest;
|
||||||
|
setGrantRequestPending(true);
|
||||||
|
void grantRequest.then(
|
||||||
|
() => {
|
||||||
|
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||||
|
setGrantRequestPending(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||||
|
setGrantRequestPending(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||||
// a late successful result useful even if the UI timeout has already
|
// a late successful result useful even if the UI timeout has already
|
||||||
// restored the Later/retry controls.
|
// returned control to the explanation.
|
||||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,14 +129,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLater = () => {
|
const handleLater = () => {
|
||||||
dismissKeychainPrompt(consentVersion);
|
if (consentVersion.trim()) {
|
||||||
|
dismissKeychainPrompt(consentVersion);
|
||||||
|
} else {
|
||||||
|
// A modal opened by an early user action can render before the async
|
||||||
|
// app-version lookup completes. Do not persist a dismissal for an
|
||||||
|
// unknown build; startup must make the final consent decision once the
|
||||||
|
// identity is known.
|
||||||
|
useSettingsStore.getState().setShowKeychainModal(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -168,17 +199,17 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
||||||
<button
|
<button
|
||||||
onClick={handleLater}
|
onClick={handleLater}
|
||||||
disabled={isGranting}
|
disabled={isGranting || grantRequestPending}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t($ => $.keychain.later)}
|
{t($ => $.keychain.later)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleGrant}
|
onClick={handleGrant}
|
||||||
disabled={isGranting}
|
disabled={isGranting || grantRequestPending}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isGranting ? t($ => $.keychain.enabling) : grantLabel}
|
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { usePlatformInfo } from '../utils/platform';
|
|||||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { localeDirection, resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [
|
const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [
|
||||||
{ type: 'downloads', icon: Download },
|
{ type: 'downloads', icon: Download },
|
||||||
@@ -252,9 +253,12 @@ const CategoryFolderInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsView() {
|
export default function SettingsView() {
|
||||||
const { t } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const activeTab = settings.activeSettingsTab;
|
const activeTab = settings.activeSettingsTab;
|
||||||
|
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||||
|
const isSidebarOnRight = settings.sidebarPosition === 'right'
|
||||||
|
|| (settings.sidebarPosition === 'auto' && isRtl);
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const platformName =
|
const platformName =
|
||||||
platform.os === 'macos'
|
platform.os === 'macos'
|
||||||
@@ -680,7 +684,9 @@ runEngineChecks(false);
|
|||||||
|
|
||||||
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
|
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
|
||||||
<div className="settings-toolbar">
|
<div className="settings-toolbar">
|
||||||
<div className="settings-tab-strip flex items-stretch gap-1">
|
<div className={`settings-tab-strip flex items-stretch gap-1 ${
|
||||||
|
isSidebarOnRight ? 'settings-tab-strip--sidebar-right' : ''
|
||||||
|
}`}>
|
||||||
{settingsTabs.map(tab => (
|
{settingsTabs.map(tab => (
|
||||||
<TabButton key={tab.type} {...tab} label={tabLabels[tab.type]} />
|
<TabButton key={tab.type} {...tab} label={tabLabels[tab.type]} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const renameQueueCancelRef = useRef<string | null>(null);
|
const renameQueueCancelRef = useRef<string | null>(null);
|
||||||
const renamingQueueIdRef = useRef<string | null>(null);
|
const renamingQueueIdRef = useRef<string | null>(null);
|
||||||
const editingQueueNameRef = useRef('');
|
const editingQueueNameRef = useRef('');
|
||||||
|
const rejectedAddQueueNameRef = useRef<string | null>(null);
|
||||||
|
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCloseMenu = () => setContextMenu(null);
|
const handleCloseMenu = () => setContextMenu(null);
|
||||||
@@ -174,9 +176,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!addQueue(normalizedName)) {
|
if (!addQueue(normalizedName)) {
|
||||||
|
if (trigger === 'blur' && rejectedAddQueueNameRef.current === normalizedName) {
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
|
setNewQueueName('');
|
||||||
|
setIsAddingQueue(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectedAddQueueNameRef.current = normalizedName;
|
||||||
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
addQueueSubmitRef.current = true;
|
addQueueSubmitRef.current = true;
|
||||||
setNewQueueName('');
|
setNewQueueName('');
|
||||||
setIsAddingQueue(false);
|
setIsAddingQueue(false);
|
||||||
@@ -202,9 +212,23 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!renameQueue(queueId, normalizedName)) {
|
if (!renameQueue(queueId, normalizedName)) {
|
||||||
|
if (
|
||||||
|
trigger === 'blur'
|
||||||
|
&& rejectedRenameRef.current?.queueId === queueId
|
||||||
|
&& rejectedRenameRef.current.name === normalizedName
|
||||||
|
) {
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
|
renamingQueueIdRef.current = null;
|
||||||
|
editingQueueNameRef.current = '';
|
||||||
|
setEditingQueueName('');
|
||||||
|
setRenamingQueueId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectedRenameRef.current = { queueId, name: normalizedName };
|
||||||
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
renameQueueSubmitRef.current = true;
|
renameQueueSubmitRef.current = true;
|
||||||
renamingQueueIdRef.current = null;
|
renamingQueueIdRef.current = null;
|
||||||
setRenamingQueueId(null);
|
setRenamingQueueId(null);
|
||||||
@@ -226,6 +250,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
value={editingQueueName}
|
value={editingQueueName}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
editingQueueNameRef.current = e.target.value;
|
editingQueueNameRef.current = e.target.value;
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
setEditingQueueName(e.target.value);
|
setEditingQueueName(e.target.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
@@ -352,7 +377,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
placeholder={t($ => $.actions.queueName)}
|
placeholder={t($ => $.actions.queueName)}
|
||||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||||
value={newQueueName}
|
value={newQueueName}
|
||||||
onChange={e => setNewQueueName(e.target.value)}
|
onChange={e => {
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
|
setNewQueueName(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') handleAddQueueSubmit();
|
if (e.key === 'Enter') handleAddQueueSubmit();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -371,6 +399,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
addQueueSubmitRef.current = false;
|
addQueueSubmitRef.current = false;
|
||||||
addQueueCancelRef.current = false;
|
addQueueCancelRef.current = false;
|
||||||
|
rejectedAddQueueNameRef.current = null;
|
||||||
setIsAddingQueue(true);
|
setIsAddingQueue(true);
|
||||||
setNewQueueName('');
|
setNewQueueName('');
|
||||||
}}
|
}}
|
||||||
@@ -454,6 +483,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
if (q) {
|
if (q) {
|
||||||
renameQueueSubmitRef.current = false;
|
renameQueueSubmitRef.current = false;
|
||||||
renameQueueCancelRef.current = null;
|
renameQueueCancelRef.current = null;
|
||||||
|
rejectedRenameRef.current = null;
|
||||||
renamingQueueIdRef.current = q.id;
|
renamingQueueIdRef.current = q.id;
|
||||||
editingQueueNameRef.current = q.name;
|
editingQueueNameRef.current = q.name;
|
||||||
setEditingQueueName(q.name);
|
setEditingQueueName(q.name);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { Minus, Square, X } from 'lucide-react';
|
import { Maximize2, Minus, X } from 'lucide-react';
|
||||||
import type { PointerEvent } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export function WindowControls({ side }: WindowControlsProps) {
|
|||||||
void appWindow.toggleMaximize();
|
void appWindow.toggleMaximize();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Square size={8} strokeWidth={3} />
|
<Maximize2 size={9} strokeWidth={3} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ const fa = {
|
|||||||
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
||||||
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
||||||
downloadLinks: 'پیوندهای دانلود',
|
downloadLinks: 'پیوندهای دانلود',
|
||||||
pastePlaceholder: 'URLهای HTTP، HTTPS، FTP یا SFTP را در اینجا جایگذاری کنید...\n\nبرای دانلود رسانه، پیوندهایی از YouTube، X، TikTok، Instagram، Reddit و غیره جایگذاری کنید.',
|
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جایگذاری کنید...\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
||||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}}{{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
playlistSummary: 'لیست پخش "{{title}}": {{loaded}}{{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (به حد نصاب ایمن ورودیها رسیدیم)',
|
safeEntryLimit: ' (به حد نصاب ایمن ورودیها رسیدیم)',
|
||||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} بازگشتی، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} بازگشتی، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||||
@@ -704,7 +704,7 @@ const fa = {
|
|||||||
firelinkIcon: 'آیکون Firelink',
|
firelinkIcon: 'آیکون Firelink',
|
||||||
unknown: 'نامشخص',
|
unknown: 'نامشخص',
|
||||||
version: 'نسخه {{version}}',
|
version: 'نسخه {{version}}',
|
||||||
description: 'مدیریت دانلود بین پلتفرمی برای macOS، Windows و Linux.',
|
description: 'مدیریت دانلود چند پلتفرمی برای macOS، Windows و Linux.',
|
||||||
sourceCode: 'کد منبع',
|
sourceCode: 'کد منبع',
|
||||||
source: 'منبع',
|
source: 'منبع',
|
||||||
upToDate: 'Firelink {{version}} بهروز است.',
|
upToDate: 'Firelink {{version}} بهروز است.',
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ const he = {
|
|||||||
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
||||||
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
||||||
downloadLinks: 'קישורי הורדה',
|
downloadLinks: 'קישורי הורדה',
|
||||||
pastePlaceholder: 'הדבק כתובות HTTP, HTTPS, FTP או SFTP כאן...\n\nעבור הורדות מדיה, הדבק קישורים מ-YouTube, X, TikTok, Instagram, Reddit וכו\'.',
|
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן...\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
||||||
playlistSummary: 'פלייליסט "{{title}}": {{loaded}} מתוך {{total}} רשומות נטענו{{truncated}}{{skipped}}',
|
playlistSummary: 'פלייליסט "{{title}}": {{loaded}} מתוך {{total}} רשומות נטענו{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (הגיע למגבלת רשומות בטוחה)',
|
safeEntryLimit: ' (הגיע למגבלת רשומות בטוחה)',
|
||||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||||
|
|||||||
+23
-2
@@ -621,7 +621,12 @@ html[data-list-density="relaxed"] {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
text-align: start;
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-links-input--rtl:placeholder-shown {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-download-link-button {
|
.add-download-link-button {
|
||||||
@@ -1004,6 +1009,9 @@ html[data-list-density="relaxed"] {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
background: hsl(var(--main-bg));
|
background: hsl(var(--main-bg));
|
||||||
|
border: 1px solid hsl(var(--border-color));
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-shell {
|
.app-sidebar-shell {
|
||||||
@@ -1332,6 +1340,10 @@ html[data-list-density="relaxed"] {
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-tab-strip--sidebar-right {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-tab-button {
|
.settings-tab-button {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1630,13 +1642,18 @@ html[data-list-density="relaxed"] {
|
|||||||
.mac-switch:checked::after {
|
.mac-switch:checked::after {
|
||||||
transform: translateX(16px);
|
transform: translateX(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Switch thumbs use physical LTR transforms in every interface direction. */
|
||||||
|
button[role="switch"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
.downloads-view {
|
.downloads-view {
|
||||||
background: hsl(var(--main-bg));
|
background: hsl(var(--main-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-controls {
|
.window-controls {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 15px;
|
top: 20px;
|
||||||
left: 22px;
|
left: 22px;
|
||||||
right: auto;
|
right: auto;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
@@ -1693,6 +1710,10 @@ html[data-list-density="relaxed"] {
|
|||||||
filter: saturate(1.14) brightness(1.05);
|
filter: saturate(1.14) brightness(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-controls:hover .window-control {
|
||||||
|
color: hsl(0 0% 8% / 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
.window-control:active {
|
.window-control:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
filter: brightness(0.9);
|
filter: brightness(0.9);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type CommandMap = {
|
|||||||
get_extension_server_port: { args: undefined; result: number | null };
|
get_extension_server_port: { args: undefined; result: number | null };
|
||||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
|
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
|
authorize_keychain_access: { args: undefined; result: void };
|
||||||
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
|
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ describe('shouldUseCustomWindowControls', () => {
|
|||||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render custom controls for macOS', () => {
|
it('keeps custom controls present for macOS while platform detection is unresolved', () => {
|
||||||
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false);
|
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||||
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false);
|
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only opts into the supported desktop platforms', () => {
|
it('only opts into the supported desktop platforms', () => {
|
||||||
expect(shouldUseCustomWindowControls('windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true);
|
expect(shouldUseCustomWindowControls('windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true);
|
||||||
expect(shouldUseCustomWindowControls('linux', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
expect(shouldUseCustomWindowControls('linux', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
|
||||||
|
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
|
||||||
expect(shouldUseCustomWindowControls('android', 'Mozilla/5.0 (Linux; Android 14)')).toBe(false);
|
expect(shouldUseCustomWindowControls('android', 'Mozilla/5.0 (Linux; Android 14)')).toBe(false);
|
||||||
|
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Linux; Android 14; Mobile)')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ const fallback: PlatformInfo = {
|
|||||||
portable: false
|
portable: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean =>
|
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean => {
|
||||||
!userAgent.includes('Mac') && (os === 'windows' || os === 'linux' || os === 'unknown');
|
if (os === 'windows' || os === 'linux' || os === 'macos') return true;
|
||||||
|
if (os !== 'unknown') return false;
|
||||||
|
|
||||||
|
// Keep the custom titlebar visible while the native platform query is
|
||||||
|
// resolving. Mobile user agents are the only unknown targets that must not
|
||||||
|
// receive desktop window controls.
|
||||||
|
return !/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent);
|
||||||
|
};
|
||||||
|
|
||||||
let cached: PlatformInfo | null = null;
|
let cached: PlatformInfo | null = null;
|
||||||
let pending: Promise<PlatformInfo> | null = null;
|
let pending: Promise<PlatformInfo> | null = null;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const buildId = (() => {
|
|||||||
'src-tauri/Cargo.lock',
|
'src-tauri/Cargo.lock',
|
||||||
'src-tauri/build.rs',
|
'src-tauri/build.rs',
|
||||||
'src-tauri/tauri.conf.json',
|
'src-tauri/tauri.conf.json',
|
||||||
|
'src-tauri/tauri.linux.conf.json',
|
||||||
|
'src-tauri/tauri.macos.conf.json',
|
||||||
|
'src-tauri/tauri.windows.conf.json',
|
||||||
'index.html',
|
'index.html',
|
||||||
'package.json',
|
'package.json',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
|
|||||||
Reference in New Issue
Block a user