diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fa01430..7df2e99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,5 +86,7 @@ jobs: run: | rustup target add x86_64-apple-darwin - uses: tauri-apps/tauri-action@v0 + env: + APPLE_SIGNING_IDENTITY: "-" with: args: ${{ matrix.args }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a78fd6e..0feb71e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -128,6 +128,7 @@ jobs: - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_SIGNING_IDENTITY: "-" with: releaseId: ${{ needs.create-release.outputs.release_id }} args: ${{ matrix.args }} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52a437e..31b02a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ struct AppState { uv_info: UvInfo, next_id: IdType, children: std::collections::HashMap, + should_offer_tcc_reset: bool, } enum SpawnedProcessState { @@ -458,6 +459,28 @@ fn kill_process_group(state: State<'_, Mutex>, id: IdType) { // tracing::info!("Process group {id} has exited"); } +#[tauri::command] +fn is_macos() -> bool { + cfg!(target_os = "macos") +} + +#[tauri::command] +fn get_should_offer_tcc_reset(state: State<'_, Mutex>) -> bool { + state.lock().unwrap().should_offer_tcc_reset +} + +#[tauri::command] +fn run_tccutil_reset() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("/usr/bin/tccutil") + .args(["reset", "SystemPolicyAllFiles", "org.strawlab.Sunbeam"]) + .output() + .map_err(|e| e.to_string())?; + } + Ok(()) +} + /// Pick a free TCP port on localhost by binding to port 0 and reading back the /// assigned port. The listener is closed immediately, so there is a small race /// window before Jupyter binds the port, but this is acceptable in practice. @@ -942,6 +965,24 @@ pub fn run() { // data, before we read or populate any defaults below. migrate_store(&store, &default_working_dir); + // On macOS, detect whether this is a new build since the last run. + // If so, the frontend will offer to reset TCC permissions, which + // can accumulate stale grants when an unsigned app is updated. + #[cfg(target_os = "macos")] + let should_offer_tcc_reset = { + let current_version = app.package_info().version.to_string(); + let last_seen = store + .get("lastKnownBuildVersion") + .and_then(|v| v.as_str().map(str::to_owned)); + // Only offer on genuine updates — suppress on first install + // (no prior grants exist yet, so nothing useful to reset). + let offer = matches!(&last_seen, Some(v) if v != ¤t_version); + store.set("lastKnownBuildVersion", serde_json::json!(current_version)); + offer + }; + #[cfg(not(target_os = "macos"))] + let should_offer_tcc_reset = false; + // // Note that values must be serde_json::Value instances, // // otherwise, they will not be compatible with the JavaScript bindings. // store.set("some-key", json!({ "value": 5 })); @@ -960,6 +1001,7 @@ pub fn run() { uv_info, next_id: Default::default(), children: Default::default(), + should_offer_tcc_reset, })); Ok(()) }) @@ -975,7 +1017,10 @@ pub fn run() { parse_pyproject_toml, process_group_state, kill_process_group, - spawn_uv + spawn_uv, + is_macos, + get_should_offer_tcc_reset, + run_tccutil_reset, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 662df76..69b3b6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { Sun, Package, Play, FolderOpen, Check, Copy, Loader2, ChevronDown, Chev import { invoke, Channel } from "@tauri-apps/api/core"; import "./App.css"; -import { open } from '@tauri-apps/plugin-dialog'; +import { open, ask } from '@tauri-apps/plugin-dialog'; import { openUrl } from '@tauri-apps/plugin-opener'; type SpawnedProcessEvent = { @@ -526,6 +526,7 @@ function App() { const [configLoaded, setConfigLoaded] = useState(false); const [workingDir, setWorkingDir] = useState(""); + const [isMacos, setIsMacos] = useState(false); // Custom package input state const [customPackageName, setCustomPackageName] = useState(""); @@ -544,11 +545,13 @@ function App() { const [uploadMessage, setUploadMessage] = useState(''); useEffect(() => { - // Load initial configuration + // Load initial configuration and check for macOS TCC reset offer Promise.all([ invoke("get_cwd"), - invoke("get_package_config") - ]).then(([cwd, config]) => { + invoke("get_package_config"), + invoke("is_macos"), + invoke("get_should_offer_tcc_reset"), + ]).then(async ([cwd, config, macos, shouldReset]) => { setWorkingDir(cwd); setPythonVersion(config.pythonVersion); @@ -566,6 +569,21 @@ function App() { setPackages(packageMap); setConfigLoaded(true); + setIsMacos(macos); + + if (shouldReset) { + const confirmed = await ask( + "Sunbeam has been updated. Due to a known macOS behavior with unsigned apps, " + + "file permissions from the previous version may not carry over correctly, " + + "which can cause repeated permission prompts.\n\n" + + "Would you like Sunbeam to reset its file access now? macOS will ask you " + + "to re-grant access the next time it's needed.", + { title: "Reset file permissions?", okLabel: "Reset Now", cancelLabel: "Not Now" } + ); + if (confirmed) { + await invoke("run_tccutil_reset"); + } + } }); }, []); @@ -684,6 +702,16 @@ function App() { }); }; + const handleManualTccReset = async () => { + const confirmed = await ask( + "This will reset Sunbeam's file access permissions. macOS will ask you to re-grant access the next time it's needed.", + { title: "Reset file permissions?", okLabel: "Reset Now", cancelLabel: "Cancel" } + ); + if (confirmed) { + await invoke("run_tccutil_reset"); + } + }; + const selectWorkingDir = async () => { const dirHandle = await open({ @@ -1065,6 +1093,20 @@ function App() { Launch Jupyter Lab + + {/* macOS file permission reset — only shown on macOS */} + {isMacos && ( +
+

Having repeated permission prompts?

+ +
+ )}