[fix] preserve macOS permissions and defer legacy migration to launch

This commit is contained in:
dijunkun
2026-11-07 03:07:24 +08:00
parent 458f9ef58d
commit f5f8eb94d4
17 changed files with 613 additions and 203 deletions
@@ -0,0 +1,25 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _MAC_PERMISSION_MIGRATION_H_
#define _MAC_PERMISSION_MIGRATION_H_
#include <string>
namespace crossdesk {
struct MacPermissionMigrationResult {
bool needs_repair = false;
std::string detail;
};
// Runs on a worker before the new app requests missing permissions. Explicit
// retries may resume failed/interrupted services, while completed ones persist.
MacPermissionMigrationResult MigrateMacPermissions(bool retry);
} // namespace crossdesk
#endif
@@ -0,0 +1,172 @@
#include "mac_permission_migration.h"
#import <Foundation/Foundation.h>
#include <ApplicationServices/ApplicationServices.h>
#include <CoreGraphics/CoreGraphics.h>
#include <Security/Security.h>
#include <nlohmann/json.hpp>
#include <sys/stat.h>
#include <signal.h>
#include <unistd.h>
#include <chrono>
#include <cerrno>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <thread>
#include "private_file.h"
#include "platform/single_instance.h"
namespace crossdesk {
namespace {
using nlohmann::json;
constexpr const char* kMigrationFile =
"/Library/Application Support/CrossDesk/permission-migration.json";
bool IsDeveloperIdProcess() {
SecCodeRef code = nullptr;
SecRequirementRef requirement = nullptr;
const auto compiled = SecRequirementCreateWithString(
CFSTR("anchor apple generic and identifier \"cn.crossdesk.app\" and "
"certificate 1[field.1.2.840.113635.100.6.2.6] exists and "
"certificate leaf[field.1.2.840.113635.100.6.1.13] exists"),
kSecCSDefaultFlags, &requirement);
const bool valid = compiled == errSecSuccess &&
SecCodeCopySelf(kSecCSDefaultFlags, &code) == errSecSuccess &&
SecCodeCheckValidity(code, kSecCSDefaultFlags, requirement) == errSecSuccess;
if (code) CFRelease(code);
if (requirement) CFRelease(requirement);
return valid;
}
bool KnownBundleId(const std::string& value) {
return value == "cn.crossdesk.app" || value == "cn.crossdesk.CrossDesk" ||
value == "com.crossdesk.app" || value == "com.crossdesk.CrossDesk" ||
value == "com.kunkundi.crossdesk" || value == "com.kunkundi.CrossDesk";
}
bool PermissionGranted(const std::string& service) {
if (service == "Accessibility") return AXIsProcessTrusted();
return CGPreflightScreenCaptureAccess();
}
bool ResetPermission(const std::string& service, const std::string& bundle_id) {
NSTask* task = [[NSTask alloc] init];
task.executableURL = [NSURL fileURLWithPath:@"/usr/bin/tccutil"];
task.arguments = @[@"reset", @(service.c_str()), @(bundle_id.c_str())];
task.standardOutput = [NSFileHandle fileHandleWithNullDevice];
task.standardError = [NSFileHandle fileHandleWithNullDevice];
NSError* error = nil;
if (![task launchAndReturnError:&error]) return false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (task.running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
if (task.running) {
kill(task.processIdentifier, SIGKILL);
[task waitUntilExit];
return false;
}
[task waitUntilExit];
return task.terminationReason == NSTaskTerminationReasonExit &&
task.terminationStatus == 0;
}
MacPermissionMigrationResult Migrate(bool retry) {
if (!IsDeveloperIdProcess()) return {};
struct stat info {};
if (lstat(kMigrationFile, &info) != 0) {
if (errno == ENOENT) return {};
throw std::runtime_error("Cannot read the permission migration marker");
}
if (!S_ISREG(info.st_mode) || info.st_uid != 0 ||
(info.st_mode & 0022) != 0 || info.st_size > 16384) {
throw std::runtime_error("Invalid permission migration marker ownership or size");
}
std::ifstream input(kMigrationFile);
const json migration = json::parse(input);
const std::string generation = migration.at("migration_id");
const std::string bundle_id = migration.at("legacy_bundle_id");
const std::string app_path = migration.at("app_path");
NSUUID* uuid = [[NSUUID alloc] initWithUUIDString:@(generation.c_str())];
if (migration.at("schema") != 1 || !uuid || !KnownBundleId(bundle_id)) {
throw std::runtime_error("Invalid permission migration marker contents");
}
if (![NSBundle.mainBundle.bundlePath isEqualToString:@(app_path.c_str())]) return {};
if (geteuid() == 0) {
throw std::runtime_error("Run CrossDesk as the logged-in user to migrate permissions");
}
platform::SingleInstanceGuard guard("CrossDeskPermissionMigration");
std::string lock_error;
if (guard.TryAcquire(platform::InstanceRole::kClient, lock_error) !=
platform::InstanceResult::kAcquired) {
throw std::runtime_error("Permission migration is already running or its lock is unavailable");
}
NSURL* support = [NSFileManager.defaultManager
URLForDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask
appropriateForURL:nil create:YES error:nullptr];
if (!support) throw std::runtime_error("Cannot find the user's application support directory");
const std::filesystem::path directory =
std::filesystem::path(support.fileSystemRepresentation) / "CrossDesk" / "PermissionMigration";
private_file::EnsureDirectory(directory, true);
const auto state_file = directory / "progress.json";
json state = {{"schema", 1}, {"migration_id", generation}, {"services", json::object()}};
if (std::filesystem::exists(state_file)) {
state = json::parse(private_file::Read(state_file));
if (state.at("schema") != 1 || !state.at("services").is_object()) {
throw std::runtime_error("Invalid per-user permission migration progress");
}
if (state.at("migration_id") != generation) {
state = {{"schema", 1}, {"migration_id", generation}, {"services", json::object()}};
}
}
MacPermissionMigrationResult result;
for (const std::string service : {"Accessibility", "ScreenCapture"}) {
const std::string progress = state["services"].value(service, "pending");
if (progress == "done") continue;
if (PermissionGranted(service)) {
// The user may have fixed this manually after a failed/interrupted reset.
state["services"][service] = "done";
private_file::Write(state_file, state.dump());
continue;
}
if (!retry && progress != "pending") {
result.needs_repair = true;
result.detail += service + " needs a manual retry. ";
continue;
}
// Persist intent before the external operation. An interrupted attempt is
// offered for explicit retry, never silently repeated at the next launch.
state["services"][service] = "attempted";
private_file::Write(state_file, state.dump());
if (ResetPermission(service, bundle_id)) {
state["services"][service] = "done";
} else {
state["services"][service] = "failed";
result.needs_repair = true;
result.detail += service + " reset failed. ";
}
private_file::Write(state_file, state.dump());
}
return result;
}
} // namespace
MacPermissionMigrationResult MigrateMacPermissions(bool retry) {
@autoreleasepool {
@try {
try {
return Migrate(retry);
} catch (const std::exception& error) {
return {true, error.what()};
}
} @catch (NSException* error) {
return {true, error.reason.UTF8String ?: "Permission migration failed"};
}
}
}
} // namespace crossdesk
@@ -28,6 +28,18 @@ void OpenPrivacyPreferences(const char *pane) {
}
} // namespace
void GuiRuntime::StartMacPermissionMigration(bool retry) {
if (mac_permission_migration_task_.valid()) return;
mac_permission_migration_failed_ = false;
try {
mac_permission_migration_task_ = std::async(
std::launch::async, [retry] { return MigrateMacPermissions(retry); });
} catch (const std::exception& error) {
mac_permission_migration_failed_ = true;
LOG_ERROR("Could not start macOS permission migration: {}", error.what());
}
}
bool GuiRuntime::CheckScreenRecordingPermission() {
// CGPreflightScreenCaptureAccess is available on macOS 10.15+
if (@available(macOS 10.15, *)) {
@@ -46,6 +58,7 @@ bool GuiRuntime::CheckAccessibilityPermission() {
}
void GuiRuntime::OpenAccessibilityPreferences() {
if (mac_permission_migration_task_.valid()) return;
if (!mac_accessibility_permission_requested_) {
NSDictionary *options = @{(__bridge id)kAXTrustedCheckOptionPrompt : @YES};
AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options);
@@ -55,6 +68,7 @@ void GuiRuntime::OpenAccessibilityPreferences() {
}
void GuiRuntime::OpenScreenRecordingPreferences() {
if (mac_permission_migration_task_.valid()) return;
if (@available(macOS 10.15, *)) {
if (!mac_screen_recording_permission_requested_) {
CGRequestScreenCaptureAccess();
@@ -67,6 +81,19 @@ void GuiRuntime::OpenScreenRecordingPreferences() {
}
void GuiRuntime::RefreshMacPermissionStatus(bool force) {
if (mac_permission_migration_task_.valid() &&
mac_permission_migration_task_.wait_for(std::chrono::seconds(0)) ==
std::future_status::ready) {
try {
const auto result = mac_permission_migration_task_.get();
mac_permission_migration_failed_ = result.needs_repair;
if (result.needs_repair) LOG_WARN("macOS permission migration: {}", result.detail);
} catch (const std::exception& error) {
mac_permission_migration_failed_ = true;
LOG_ERROR("macOS permission migration failed: {}", error.what());
}
force = true;
}
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
if (!force && mac_permission_status_initialized_ &&
now - mac_permission_last_check_tick_ <
@@ -98,7 +125,7 @@ bool GuiRuntime::EnsureMacScreenRecordingPermission() {
return true;
}
show_request_permission_window_ = true;
show_request_permission_window_ = !mac_permission_dialog_dismissed_;
return false;
}
@@ -108,7 +135,7 @@ bool GuiRuntime::EnsureMacAccessibilityPermission() {
return true;
}
show_request_permission_window_ = true;
show_request_permission_window_ = !mac_permission_dialog_dismissed_;
return false;
}