fix(service): show how to reclaim an app data root the service refuses

When the service refuses the app data root because another uid owns it
(code 1012), show a sticky notice with the chown command for this
account. Repairing the service cannot fix this.

Refs #8026
This commit is contained in:
wonfen
2026-09-24 22:10:42 +08:00
parent 04d74ac48f
commit 1f140dfd3a
24 changed files with 97 additions and 3 deletions
+1
View File
@@ -15,6 +15,7 @@
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
</details>
+5
View File
@@ -124,6 +124,11 @@ pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
+22 -2
View File
@@ -21,8 +21,9 @@ use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest,
WriterConfig,
};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
@@ -40,6 +41,7 @@ static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
@@ -55,6 +57,19 @@ pub(crate) fn take_service_repair_notice() -> bool {
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
}
/// Returns the command that gives the app data root back to this account.
pub(crate) fn take_service_owner_notice() -> Option<String> {
PENDING_SERVICE_OWNER_NOTICE.lock().take()
}
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
return None;
};
let path = credentials.app_data_dir.replace('\'', r"'\''");
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
#[derive(Clone)]
@@ -851,6 +866,10 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::repair_required", "");
}
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
Handle::notice_message("service_core::app_data_not_owned", "");
}
start_owner_monitor();
return Err(record_service_start_refusal(
&RUN_STATE,
@@ -880,6 +899,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_OWNER_NOTICE.lock().take();
logging!(
info,
Type::Service,
+1
View File
@@ -173,6 +173,7 @@ mod app_init {
cmd::take_service_fallback_notice,
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
+25
View File
@@ -25,6 +25,8 @@ const SERVICE_PERMISSION_NOTICE =
'settings.feedback.notifications.clashService.permissionFallback'
const SERVICE_PERMISSION_GUIDE =
'https://clash-verge-rev.github.io/faq/windows.html#service-core-permissions'
const SERVICE_OWNER_NOTICE =
'settings.feedback.notifications.clashService.appDataNotOwned'
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
@@ -95,6 +97,26 @@ const resolveNoticeMessage = (
)
}
if (i18n.key === SERVICE_OWNER_NOTICE) {
return (
<>
{t(SERVICE_OWNER_NOTICE)}
<Box
component="code"
sx={{
display: 'block',
mt: 1,
userSelect: 'text',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{String(i18n.params?.command ?? '')}
</Box>
</>
)
}
const source = (i18n.params ?? {}) as Record<string, unknown>
// Bound both parameters and their final interpolation.
const params =
@@ -171,6 +193,9 @@ const resolveNoticeCopyText = (
if (notice.i18n?.key === SERVICE_PERMISSION_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.reason)
}
if (notice.i18n?.key === SERVICE_OWNER_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.command)
}
if (
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
notice.i18n?.key === 'shared.feedback.notices.raw'
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "تم تحديث البيانات الجغرافية"
},
"clashService": {
"appDataNotOwned": "رفضت الخدمة تشغيل النواة لأن مالك مجلد بيانات التطبيق على القرص ليس حسابك (يحدث غالبًا عندما يكون مجلد المنزل على محرك أقراص خارجي). لن يؤدي إصلاح الخدمة إلى حل المشكلة. شغّل هذا الأمر في الطرفية، ثم أعد تشغيل التطبيق:",
"installSuccess": "تم تثبيت الخدمة بنجاح",
"permissionFallback": "{{reason}} تم التبديل إلى وضع Sidecar؛ يتطلب TUN صلاحيات المسؤول. اتبع الدليل للإصلاح وإعادة تثبيت الخدمة، أو شغّل التطبيق كمسؤول دون تثبيت الخدمة.",
"permissionWritableReason": "يمكن للحسابات العادية تعديل ملفات النواة أو أحد المجلدات الأصلية، لذا رفضت الخدمة تشغيل النواة بصلاحيات مرتفعة.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "Geo-Daten wurden aktualisiert"
},
"clashService": {
"appDataNotOwned": "Der Dienst hat den Start des Kerns abgelehnt, weil der App-Datenordner auf dem Datenträger nicht Ihrem Konto gehört (häufig, wenn der Benutzerordner auf einem externen Laufwerk liegt). Eine Reparatur des Dienstes behebt das nicht. Führen Sie diesen Befehl im Terminal aus und starten Sie die App danach neu:",
"installSuccess": "Service erfolgreich installiert",
"permissionFallback": "{{reason}} Zum Sidecar-Modus gewechselt; TUN benötigt Administratorrechte. Reparieren Sie den Dienst gemäß Anleitung und installieren Sie ihn neu, oder starten Sie die App als Administrator ohne Dienstinstallation.",
"permissionWritableReason": "Normale Konten können Kerndateien oder übergeordnete Ordner ändern. Daher verweigert der Dienst die Ausführung mit erhöhten Rechten.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData Updated"
},
"clashService": {
"appDataNotOwned": "The service refused to start the core because, on disk, the app data folder is not owned by your account (common when the home folder is on an external drive). Repairing the service will not fix this. Run this command in a terminal, then restart the app:",
"installSuccess": "Service Installed Successfully",
"permissionFallback": "{{reason}} Switched to normal mode (Sidecar); TUN requires administrator privileges. Follow the guide to fix and reinstall the service. If you prefer not to fix it or do not know how, run the app as administrator without installing the service.",
"permissionWritableReason": "Ordinary accounts can modify the core files or a parent folder, so the service refused to run the core with elevated privileges.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData actualizado"
},
"clashService": {
"appDataNotOwned": "El servicio se negó a iniciar el núcleo porque, en el disco, la carpeta de datos de la aplicación no pertenece a su cuenta (es habitual cuando la carpeta de inicio está en una unidad externa). Reparar el servicio no lo soluciona. Ejecute este comando en una terminal y luego reinicie la aplicación:",
"installSuccess": "Servicio instalado con éxito",
"permissionFallback": "{{reason}} Se ha cambiado al modo Sidecar; TUN requiere permisos de administrador. Siga la guía para reparar y reinstalar el servicio, o ejecute la aplicación como administrador sin instalar el servicio.",
"permissionWritableReason": "Las cuentas normales pueden modificar los archivos del núcleo o una carpeta superior, por lo que el servicio rechazó ejecutarlo con privilegios elevados.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData به‌روزرسانی شد"
},
"clashService": {
"appDataNotOwned": "سرویس از اجرای هسته خودداری کرد، زیرا مالک پوشه داده‌های برنامه روی دیسک، حساب شما نیست (معمولاً وقتی پوشه خانگی روی درایو خارجی باشد). تعمیر سرویس این مشکل را حل نمی‌کند. این دستور را در ترمینال اجرا کنید و سپس برنامه را دوباره اجرا کنید:",
"installSuccess": "سرویس با موفقیت نصب شد",
"permissionFallback": "{{reason}} به حالت Sidecar تغییر یافت؛ TUN به دسترسی مدیر نیاز دارد. طبق راهنما مشکل را برطرف و سرویس را دوباره نصب کنید، یا برنامه را بدون نصب سرویس با دسترسی مدیر اجرا کنید.",
"permissionWritableReason": "حساب‌های عادی می‌توانند فایل‌های هسته یا پوشهٔ والد را تغییر دهند، بنابراین سرویس از اجرای هسته با دسترسی بالا خودداری کرد.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData Diperbarui"
},
"clashService": {
"appDataNotOwned": "Layanan menolak memulai inti karena, di disk, folder data aplikasi tidak dimiliki oleh akun Anda (sering terjadi jika folder beranda berada di drive eksternal). Memperbaiki layanan tidak akan mengatasinya. Jalankan perintah ini di terminal, lalu mulai ulang aplikasi:",
"installSuccess": "Layanan Berhasil Diinstal",
"permissionFallback": "{{reason}} Beralih ke mode Sidecar; TUN memerlukan hak administrator. Ikuti panduan untuk memperbaiki dan menginstal ulang layanan, atau jalankan aplikasi sebagai administrator tanpa menginstal layanan.",
"permissionWritableReason": "Akun biasa dapat mengubah berkas inti atau folder induknya, sehingga layanan menolak menjalankan inti dengan hak istimewa.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoDataが更新されました。"
},
"clashService": {
"appDataNotOwned": "サービスがコアの起動を拒否しました。ディスク上のアプリデータフォルダーの所有者が現在のアカウントではありません(ホームフォルダーが外付けドライブにある場合によく起こります)。サービスを修復しても解決しません。ターミナルで次のコマンドを実行してから、アプリを再起動してください:",
"installSuccess": "サービスのインストールに成功しました。",
"permissionFallback": "{{reason}} 通常モード(Sidecar)に切り替えました。TUN には管理者権限が必要です。手順に従って修復しサービスを再インストールするか、サービスをインストールせずにアプリを管理者として実行してください。",
"permissionWritableReason": "一般アカウントがコアファイルまたは上位フォルダーを変更できるため、サービスは高い権限での実行を拒否しました。",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData가 업데이트되었습니다"
},
"clashService": {
"appDataNotOwned": "서비스가 코어 시작을 거부했습니다. 디스크상의 앱 데이터 폴더 소유자가 현재 계정이 아닙니다(홈 폴더가 외장 드라이브에 있을 때 흔히 발생). 서비스를 복구해도 해결되지 않습니다. 터미널에서 다음 명령을 실행한 후 앱을 다시 시작하세요:",
"installSuccess": "서비스가 설치되었습니다",
"permissionFallback": "{{reason}} 일반 모드(Sidecar)로 전환했습니다. TUN에는 관리자 권한이 필요합니다. 안내에 따라 수정 후 서비스를 다시 설치하거나, 서비스 설치 없이 앱을 관리자 권한으로 실행하세요.",
"permissionWritableReason": "일반 계정이 코어 파일이나 상위 폴더를 수정할 수 있어 서비스가 높은 권한으로 실행하는 것을 거부했습니다.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "Файлы GeoData обновлены"
},
"clashService": {
"appDataNotOwned": "Служба отказалась запускать ядро: на диске папка данных приложения принадлежит не вашей учётной записи (часто бывает, если домашняя папка находится на внешнем диске). Восстановление службы это не исправит. Выполните в терминале следующую команду и перезапустите приложение:",
"installSuccess": "Служба успешно установлена",
"permissionFallback": "{{reason}} Выполнен переход в режим Sidecar; TUN требует прав администратора. Исправьте проблему по инструкции и переустановите службу либо запустите приложение от имени администратора без установки службы.",
"permissionWritableReason": "Обычные учётные записи могут изменять файлы ядра или родительскую папку, поэтому служба отказалась запускать ядро с повышенными правами.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData Güncellendi"
},
"clashService": {
"appDataNotOwned": "Hizmet çekirdeği başlatmayı reddetti; çünkü diskte uygulama veri klasörünün sahibi hesabınız değil (ana klasör harici bir sürücüdeyse sık görülür). Hizmeti onarmak bunu düzeltmez. Bu komutu bir terminalde çalıştırın, ardından uygulamayı yeniden başlatın:",
"installSuccess": "Hizmet Başarıyla Kuruldu",
"permissionFallback": "{{reason}} Sidecar moduna geçildi; TUN yönetici yetkisi gerektirir. Kılavuza göre sorunu düzeltip hizmeti yeniden yükleyin veya hizmeti yüklemeden uygulamayı yönetici olarak çalıştırın.",
"permissionWritableReason": "Normal hesaplar çekirdek dosyalarını veya üst klasörü değiştirebildiği için hizmet, çekirdeği yükseltilmiş yetkilerle çalıştırmayı reddetti.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "GeoData яңартылды"
},
"clashService": {
"appDataNotOwned": "Хезмәт төшне эшләтеп җибәрүдән баш тартты, чөнки дисктагы кушымта мәгълүматлары папкасының хуҗасы сезнең хисап язмасы түгел (өй папкасы тышкы дискта булганда еш очрый). Хезмәтне төзәтү моны хәл итмәячәк. Бу боерыкны терминалда башкарыгыз, аннары кушымтаны яңадан җибәрегез:",
"installSuccess": "Сервис уңышлы урнаштырылды",
"permissionFallback": "{{reason}} Sidecar режимына күчелде; TUN өчен администратор хокуклары кирәк. Күрсәтмәләр буенча төзәтеп, хезмәтне яңадан урнаштырыгыз яки хезмәтне урнаштырмыйча кушымтаны администратор булып эшләтегез.",
"permissionWritableReason": "Гади хисап язмалары төш файлларын яки өске папканы үзгәртә ала, шуңа күрә хезмәт төшне югары хокуклар белән эшләтүдән баш тартты.",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "已更新 GeoData"
},
"clashService": {
"appDataNotOwned": "服务拒绝启动内核:磁盘上应用数据目录的所有者不是当前账户(常见于用户目录位于外置磁盘)。修复服务无法解决此问题,请在终端运行以下命令,然后重启软件:",
"installSuccess": "已成功安装服务",
"permissionFallback": "{{reason}} 已切换到普通模式(Sidecar),普通权限下无法使用 TUN。可按文档修复后重装服务;不想或不会修复时,可直接以管理员身份运行软件,无需安装服务。",
"permissionWritableReason": "内核文件或上级文件夹允许普通账户修改,服务为防止内核被替换而拒绝高权限运行。",
+1
View File
@@ -683,6 +683,7 @@
"geoDataUpdated": "已更新 GeoData"
},
"clashService": {
"appDataNotOwned": "服務拒絕啟動核心:磁碟上應用程式資料目錄的擁有者不是目前帳戶(常見於使用者目錄位於外接磁碟)。修復服務無法解決此問題,請在終端機執行以下命令,然後重新啟動軟體:",
"installSuccess": "已成功安裝服務",
"permissionFallback": "{{reason}} 已切換至一般模式(Sidecar),一般權限下無法使用 TUN。可依文件修復後重新安裝服務;不想或不會修復時,可直接以系統管理員身分執行軟體,無需安裝服務。",
"permissionWritableReason": "核心檔案或上層資料夾允許一般帳戶修改,服務為防止核心被替換而拒絕以高權限執行。",
@@ -32,6 +32,7 @@ vi.mock('@/services/cmds', () => ({
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
takeServiceRepairNotice: vi.fn().mockResolvedValue(false),
takeServiceOwnerNotice: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/services/service-request', () => ({ requestService: vi.fn() }))
vi.mock('@/services/notice-service', () => ({
@@ -68,6 +68,7 @@ export const useLayoutEvents = (
handleNotice(['enhance::discarded_keys', ''])
handleNotice(['service_core::sidecar_fallback', ''])
handleNotice(['service_core::repair_required', ''])
handleNotice(['service_core::app_data_not_owned', ''])
handleNotice(['core_start::error', ''])
},
)
@@ -5,15 +5,17 @@ import {
takeDiscardedKeysNotice,
takeDnsOverrideNotice,
takeServiceFallbackNotice,
takeServiceOwnerNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { hideNotice, showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
type NavigateFunction = (path: string, options?: any) => void
type TranslateFunction = (key: string) => string
let shownStartupError: string | null = null
let shownOwnerNotice: number | null = null
export const handleNoticeMessage = (
status: string,
@@ -74,6 +76,25 @@ export const handleNoticeMessage = (
)
})
},
'service_core::app_data_not_owned': () => {
void takeServiceOwnerNotice()
.then((command) => {
if (command) {
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
shownOwnerNotice = showNotice.warning(
'settings.feedback.notifications.clashService.appDataNotOwned',
{ command },
0,
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service owner notice',
error,
)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((pending) => {
+4
View File
@@ -198,6 +198,10 @@ export async function takeServiceRepairNotice() {
return invoke<boolean>('take_service_repair_notice')
}
export async function takeServiceOwnerNotice() {
return invoke<string | null>('take_service_owner_notice')
}
export async function takeDiscardedKeysNotice() {
return invoke<string | null>('take_discarded_keys_notice')
}
+1
View File
@@ -803,6 +803,7 @@ export const translationKeys = [
'settings.feedback.notifications.clash.changeSuccess',
'settings.feedback.notifications.clash.changeFailed',
'settings.feedback.notifications.clash.geoDataUpdated',
'settings.feedback.notifications.clashService.appDataNotOwned',
'settings.feedback.notifications.clashService.installSuccess',
'settings.feedback.notifications.clashService.permissionFallback',
'settings.feedback.notifications.clashService.permissionWritableReason',
+1
View File
@@ -844,6 +844,7 @@ export interface TranslationResources {
versionUpdated: string
}
clashService: {
appDataNotOwned: string
installSuccess: string
permissionFallback: string
permissionRejectedReason: string