[ENG-1413] Full disk access perms (#1791)

* fda wip

* clippy

* add tauri invoke fns for FDA

* fda wip

* clippy

* add tauri invoke fns for FDA

* wip

* fda wip

* clippy

* add tauri invoke fns for FDA

* wip

* wip

* wip fda

* remove imports

* hopefully improve FDA

* execute only on macos

* ts

* ts

* Update Platform.tsx

* Update AddLocationButton.tsx

* remove console log

* fix: fda and add unit tests

* temp commit for Jake

* add fda state and keybind handling (so the frontend is kept up to date)

* update FDA

* update imports

* testing purposes

* Jakes work

* fix fda checks

* work in progress (but not working)

* remove dead files

* attempt #2

* !!!temporarily enable devtools in prod

* remove alert

* show FDA screen but don't require it

* add an FDA button to general client settings

* Update AddLocationButton.tsx

* remove dead code

* unused dep

* old errors

* remove import

* dead code

* dead code + typesafety

* eslint

* remove fda dialog references

* remove mp4 vid

* hopefully fix onboarding for non-macos OSes

* shorter nav

---------

Co-authored-by: jake <77554505+brxken128@users.noreply.github.com>
This commit is contained in:
ameer2468 2023-11-23 23:54:45 +03:00 committed by GitHub
parent 797e4821b6
commit a7fed83556
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 210 additions and 176 deletions

12
Cargo.lock generated
View file

@ -1677,15 +1677,6 @@ dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
@ -6939,6 +6930,7 @@ dependencies = [
"sd-desktop-linux",
"sd-desktop-macos",
"sd-desktop-windows",
"sd-fda",
"sd-prisma",
"serde",
"specta",
@ -6983,9 +6975,7 @@ dependencies = [
name = "sd-fda"
version = "0.1.0"
dependencies = [
"dirs",
"thiserror",
"tokio",
]
[[package]]

View file

@ -10,24 +10,25 @@ edition = { workspace = true }
[dependencies]
tauri = { version = "1.5.2", features = [
"dialog-all",
"linux-protocol-headers",
"macos-private-api",
"os-all",
"path-all",
"protocol-all",
"shell-all",
"updater",
"window-all",
"native-tls-vendored",
"macos-private-api",
"path-all",
"protocol-all",
"os-all",
"shell-all",
"dialog-all",
"linux-protocol-headers",
"updater",
"window-all",
"native-tls-vendored",
] }
rspc = { workspace = true, features = ["tauri"] }
sd-core = { path = "../../../core", features = [
"ffmpeg",
"location-watcher",
"heif",
"ffmpeg",
"location-watcher",
"heif",
] }
sd-fda = { path = "../../../crates/fda" }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
serde = "1.0.190"

View file

@ -7,6 +7,7 @@ use std::{fs, path::PathBuf, sync::Arc, time::Duration};
use sd_core::{Node, NodeError};
use sd_fda::DiskAccess;
use tauri::{
api::path, ipc::RemoteDomainAccessScope, window::PlatformWebview, AppHandle, Manager,
WindowEvent,
@ -27,10 +28,16 @@ mod updater;
#[specta::specta]
async fn app_ready(app_handle: AppHandle) {
let window = app_handle.get_window("main").unwrap();
window.show().unwrap();
}
#[tauri::command(async)]
#[specta::specta]
// If this erorrs, we don't have FDA and we need to re-prompt for it
async fn request_fda_macos() {
DiskAccess::request_fda().expect("Unable to request full disk access");
}
#[tauri::command(async)]
#[specta::specta]
async fn set_menu_bar_item_state(_window: tauri::Window, _id: String, _enabled: bool) {
@ -302,6 +309,7 @@ async fn main() -> tauri::Result<()> {
refresh_menu_bar,
reload_webview,
set_menu_bar_item_state,
request_fda_macos,
file::open_file_paths,
file::open_ephemeral_files,
file::get_file_path_open_with_apps,

View file

@ -17,6 +17,8 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style/style.scss';
import { useOperatingSystem } from '@sd/interface/hooks';
import * as commands from './commands';
import { platform } from './platform';
import { queryClient } from './query';
@ -79,9 +81,10 @@ export default function App() {
const TAB_CREATE_DELAY = 150;
function AppInner() {
const os = useOperatingSystem();
function createTab() {
const history = createMemoryHistory();
const router = createMemoryRouterWithHistory({ routes, history });
const router = createMemoryRouterWithHistory({ routes: routes(os), history });
const dispose = router.subscribe((event) => {
setTabs((routers) => {

View file

@ -34,6 +34,10 @@ export function setMenuBarItemState(id: string, enabled: boolean) {
return invoke()<null>("set_menu_bar_item_state", { id,enabled })
}
export function requestFdaMacos() {
return invoke()<null>("request_fda_macos")
}
export function openFilePaths(library: string, ids: number[]) {
return invoke()<OpenFilePathResult[]>("open_file_paths", { library,ids })
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { RspcProvider } from '@sd/client';
import { Platform, PlatformProvider, routes, SpacedriveInterface } from '@sd/interface';
import { useShowControls } from '@sd/interface/hooks';
import { useOperatingSystem, useShowControls } from '@sd/interface/hooks';
import demoData from './demoData.json';
import ScreenshotWrapper from './ScreenshotWrapper';
@ -76,8 +76,9 @@ const queryClient = new QueryClient({
});
function App() {
const os = useOperatingSystem();
const [router, setRouter] = useState(() => {
const router = createBrowserRouter(routes);
const router = createBrowserRouter(routes(os));
router.subscribe((event) => {
setRouter((router) => {

View file

@ -2,6 +2,5 @@ pub mod ping;
pub mod request_file;
pub mod spacedrop;
pub use ping::ping;
pub use request_file::request_file;
pub use spacedrop::spacedrop;

View file

@ -7,6 +7,4 @@ repository = { workspace = true }
edition = { workspace = true }
[dependencies]
dirs = "5.0.1"
tokio = { workspace = true, features = ["rt-multi-thread", "fs", "macros"] }
thiserror = "1.0.50"

View file

@ -1,7 +1,5 @@
# Spacedrive FDA Handling
## Platforms
### MacOS
## MacOS
On MacOS, we are able to open the "Full disk access" settings prompt to instruct the user to allow Spacedrive full disk access, which should alleviate all permissions issues.

View file

@ -1,10 +1,5 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unable to access path: {0}")]
PermissionDenied(PathBuf),
#[cfg(target_os = "macos")]
#[error("there was an error while prompting for full disk access")]
FDAPromptError,

View file

@ -24,43 +24,20 @@
#![forbid(unsafe_code, deprecated_in_future)]
#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)]
use std::{io::ErrorKind, path::PathBuf};
use dirs::{
audio_dir, cache_dir, config_dir, config_local_dir, data_dir, data_local_dir, desktop_dir,
document_dir, download_dir, executable_dir, home_dir, picture_dir, preference_dir, public_dir,
runtime_dir, state_dir, template_dir, video_dir,
};
pub mod error;
use error::Result;
pub struct FullDiskAccess(Vec<PathBuf>);
pub struct DiskAccess;
impl FullDiskAccess {
async fn can_access_path(path: PathBuf) -> bool {
match tokio::fs::read_dir(path).await {
Ok(_) => true,
Err(e) => !matches!(e.kind(), ErrorKind::PermissionDenied),
}
}
pub async fn has_fda() -> bool {
let dirs = Self::default();
for dir in dirs.0 {
if !Self::can_access_path(dir).await {
return false;
}
}
true
}
#[allow(clippy::missing_const_for_fn)]
impl DiskAccess {
/// This function is a no-op on non-MacOS systems.
///
/// Once ran, it will open the "Full Disk Access" prompt.
pub fn request_fda() -> Result<()> {
#[cfg(target_os = "macos")]
{
use error::Error;
use crate::error::Error;
use std::process::Command;
Command::new("open")
@ -73,49 +50,14 @@ impl FullDiskAccess {
}
}
impl Default for FullDiskAccess {
fn default() -> Self {
Self(
[
audio_dir(),
cache_dir(),
config_dir(),
config_local_dir(),
data_dir(),
data_local_dir(),
desktop_dir(),
document_dir(),
download_dir(),
executable_dir(),
home_dir(),
picture_dir(),
preference_dir(),
public_dir(),
runtime_dir(),
state_dir(),
template_dir(),
video_dir(),
]
.into_iter()
.flatten()
.collect(),
)
}
}
#[cfg(test)]
mod tests {
use super::FullDiskAccess;
use super::DiskAccess;
#[test]
#[cfg_attr(miri, ignore = "Miri can't run this test")]
#[ignore = "CI can't run this due to lack of a GUI"]
fn macos_open_full_disk_prompt() {
FullDiskAccess::request_fda().unwrap();
}
#[tokio::test]
async fn has_fda() {
FullDiskAccess::has_fda().await;
DiskAccess::request_fda().unwrap();
}
}

View file

@ -11,7 +11,7 @@ import {
} from '@sd/client';
import { Button, Card, Input, Select, SelectOption, Slider, Switch, tw, z } from '@sd/ui';
import { Icon } from '~/components';
import { useDebouncedFormWatch } from '~/hooks';
import { useDebouncedFormWatch, useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { Heading } from '../Layout';
@ -29,6 +29,8 @@ export const Component = () => {
const debugState = useDebugState();
const editNode = useBridgeMutation('nodes.edit');
const connectedPeers = useConnectedPeers();
const os = useOperatingSystem();
const { requestFdaMacos } = usePlatform();
const updateThumbnailerPreferences = useBridgeMutation('nodes.updateThumbnailerPreferences');
const form = useZodForm({
@ -178,6 +180,18 @@ export const Component = () => {
</div>
</Card>
{os === 'macOS' && (
<Setting
mini
title="Full disk access"
description="Enable full disk access to allow Spacedrive to index additional files."
>
<Button onClick={requestFdaMacos} variant="gray" size="sm" className="my-5">
Enable
</Button>
</Setting>
)}
<Setting
mini
title="Debug mode"

View file

@ -2,7 +2,6 @@ import { FolderSimplePlus } from '@phosphor-icons/react';
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useLibraryContext } from '@sd/client';
import { Button, dialogManager, type ButtonProps } from '@sd/ui';
import { useCallbackToWatchResize } from '~/hooks';
@ -19,7 +18,6 @@ interface AddLocationButton extends ButtonProps {
export const AddLocationButton = ({ path, className, onClick, ...props }: AddLocationButton) => {
const platform = usePlatform();
const libraryId = useLibraryContext().library.uuid;
const navigate = useNavigate();
const transition = {
type: 'keyframes',
@ -41,28 +39,30 @@ export const AddLocationButton = ({ path, className, onClick, ...props }: AddLoc
setIsOverflowing(text.scrollWidth > overflow.clientWidth);
}, [overflowRef, textRef]);
const locationDialogHandler = async () => {
if (!path) {
path = (await openDirectoryPickerDialog(platform)) ?? undefined;
}
// Remember `path` will be `undefined` on web cause the user has to provide it in the modal
if (path !== '')
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} libraryId={libraryId} {...dp} />
));
};
return (
<>
<Button
variant="dotted"
className={clsx('w-full', className)}
onClick={async () => {
if (!path) {
path = (await openDirectoryPickerDialog(platform)) ?? undefined;
}
// Remember `path` will be `undefined` on web cause the user has to provide it in the modal
if (path !== '')
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} libraryId={libraryId} {...dp} />
));
await locationDialogHandler();
onClick?.();
}}
{...props}
>
{path ? (
<div className="flex h-full w-full flex-row items-end whitespace-nowrap font-mono text-sm">
<div className="flex flex-row items-end w-full h-full font-mono text-sm whitespace-nowrap">
<FolderSimplePlus size={22} className="shrink-0" />
<div className="ml-1 overflow-hidden">
<motion.span

View file

@ -10,6 +10,10 @@ import { RootContext } from './RootContext';
import './style.scss';
import { useOperatingSystem } from '~/hooks';
import { OperatingSystem } from '..';
const Index = () => {
const libraries = useCachedLibraries();
@ -40,28 +44,30 @@ const Wrapper = () => {
// the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself).
// the hook should only be included if there's a valid `ClientContext` (so not onboarding)
export const routes = [
{
element: <Wrapper />,
errorElement: <RouterErrorBoundary />,
children: [
{
index: true,
element: <Index />
},
{
path: 'onboarding',
lazy: () => import('./onboarding/Layout'),
children: onboardingRoutes
},
{
path: ':libraryId',
lazy: () => import('./$libraryId/Layout'),
children: libraryRoutes
}
]
}
] satisfies RouteObject[];
export const routes = (os: OperatingSystem) => {
return [
{
element: <Wrapper />,
errorElement: <RouterErrorBoundary />,
children: [
{
index: true,
element: <Index />
},
{
path: 'onboarding',
lazy: () => import('./onboarding/Layout'),
children: onboardingRoutes(os)
},
{
path: ':libraryId',
lazy: () => import('./$libraryId/Layout'),
children: libraryRoutes
}
]
}
] satisfies RouteObject[];
};
/**
* Combines the `path` segments of the current route into a single string.
@ -72,6 +78,7 @@ const useRawRoutePath = () => {
// `useMatches` returns a list of each matched RouteObject,
// we grab the last one as it contains all previous route segments.
const lastMatchId = useMatches().slice(-1)[0]?.id;
const os = useOperatingSystem();
const rawPath = useMemo(() => {
const [rawPath] =
@ -93,11 +100,11 @@ const useRawRoutePath = () => {
// `path` found, chuck it on the end
return [`${rawPath}/${item.path}`, item];
},
['' as string, { children: routes }] as const
['' as string, { children: routes(os) }] as const
) ?? [];
return rawPath ?? '/';
}, [lastMatchId]);
}, [lastMatchId, os]);
return rawPath;
};

View file

@ -2,12 +2,14 @@ import clsx from 'clsx';
import { useEffect } from 'react';
import { useMatch, useNavigate } from 'react-router';
import { getOnboardingStore, unlockOnboardingScreen, useOnboardingStore } from '@sd/client';
import { useOperatingSystem } from '~/hooks';
import routes from '.';
export default function OnboardingProgress() {
const obStore = useOnboardingStore();
const navigate = useNavigate();
const os = useOperatingSystem();
const match = useMatch('/onboarding/:screen');
@ -22,7 +24,7 @@ export default function OnboardingProgress() {
return (
<div className="flex w-full items-center justify-center">
<div className="flex items-center justify-center space-x-1">
{routes.map(({ path }) => {
{routes(os).map(({ path }) => {
if (!path) return null;
return (

View file

@ -0,0 +1,63 @@
import { Fda } from '@sd/assets/videos';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@sd/ui';
import { Icon } from '~/components';
import { usePlatform } from '~/util/Platform';
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './components';
export const FullDisk = () => {
const { requestFdaMacos } = usePlatform();
const [showVideo, setShowVideo] = useState(false);
const navigate = useNavigate();
return (
<OnboardingContainer>
<Icon name="HDD" size={80} />
<OnboardingTitle>Full disk access</OnboardingTitle>
<OnboardingDescription>
To provide the best experience, we need access to your disk in order to index your
files. Your files are only available to you.
</OnboardingDescription>
{!showVideo ? (
<>
<div className="flex items-center gap-3">
<Button onClick={requestFdaMacos} variant="gray" size="sm" className="my-5">
Enable access
</Button>
<Button onClick={() => setShowVideo((t) => !t)} variant="outline">
How to enable
</Button>
</div>
</>
) : (
<div className="mt-5 w-full max-w-[450px]">
<video className="rounded-md" autoPlay loop muted controls={false} src={Fda} />
</div>
)}
<div className="flex gap-3">
<Button
onClick={() => {
navigate('../locations', { replace: true });
}}
variant="accent"
size="sm"
className="mt-8"
>
Continue
</Button>
{showVideo && (
<Button
onClick={() => setShowVideo((t) => !t)}
variant="gray"
size="sm"
className="mt-8"
>
Close
</Button>
)}
</div>
</OnboardingContainer>
);
};

View file

@ -1,9 +1,11 @@
import { Navigate, RouteObject } from 'react-router';
import { getOnboardingStore } from '@sd/client';
import { OperatingSystem } from '~/util/Platform';
import Alpha from './alpha';
import { useOnboardingContext } from './context';
import CreatingLibrary from './creating-library';
import { FullDisk } from './full-disk';
import Locations from './locations';
import NewLibrary from './new-library';
import Privacy from './privacy';
@ -18,30 +20,16 @@ const Index = () => {
return <Navigate to="alpha" replace />;
};
export default [
{
index: true,
element: <Index />
},
{ path: 'alpha', element: <Alpha /> },
// {
// element: <Login />,
// path: 'login'
// },
{
element: <NewLibrary />,
path: 'new-library'
},
{
element: <Locations />,
path: 'locations'
},
{
element: <Privacy />,
path: 'privacy'
},
{
element: <CreatingLibrary />,
path: 'creating-library'
}
] satisfies RouteObject[];
const onboardingRoutes = (os: OperatingSystem) => {
return [
{ index: true, element: <Index /> },
{ path: 'alpha', element: <Alpha /> },
{ path: 'new-library', element: <NewLibrary /> },
...(os === 'macOS' ? [{ element: <FullDisk />, path: 'full-disk' }] : []),
{ path: 'locations', element: <Locations /> },
{ path: 'privacy', element: <Privacy /> },
{ path: 'creating-library', element: <CreatingLibrary /> }
] satisfies RouteObject[];
};
export default onboardingRoutes;

View file

@ -90,6 +90,19 @@ export default function OnboardingLocations() {
className="flex flex-col items-center"
>
<OnboardingContainer>
<div className="flex items-center">
<Icon
name="Folder"
size={40}
className="relative right-[-26px] z-0 brightness-[0.5]"
/>
<Icon name="Folder" size={60} className="relative z-[5] brightness-[0.8]" />
<Icon
name="Folder"
size={46}
className="relative left-[-25px] z-[0] brightness-[0.6]"
/>
</div>
<OnboardingTitle>Add Locations</OnboardingTitle>
<OnboardingDescription>
Enhance your Spacedrive experience by adding your favorite locations to your

View file

@ -2,12 +2,14 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button, Form, InputField } from '@sd/ui';
import { Icon } from '~/components';
import { useOperatingSystem } from '~/hooks';
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './components';
import { useOnboardingContext } from './context';
export default function OnboardingNewLibrary() {
const navigate = useNavigate();
const os = useOperatingSystem();
const form = useOnboardingContext().forms.useForm('new-library');
const [importMode, setImportMode] = useState(false);
@ -20,11 +22,11 @@ export default function OnboardingNewLibrary() {
<Form
form={form}
onSubmit={form.handleSubmit(() => {
navigate('../locations', { replace: true });
navigate(`../${os === 'macOS' ? 'full-disk' : 'locations'}`, { replace: true });
})}
>
<OnboardingContainer>
<Icon name="Database" size={80} className="mb-2" />
<Icon name="Database" size={80} />
<OnboardingTitle>Create a Library</OnboardingTitle>
<OnboardingDescription>
Libraries are a secure, on-device database. Your files remain where they are,
@ -32,7 +34,7 @@ export default function OnboardingNewLibrary() {
</OnboardingDescription>
{importMode ? (
<div className="mt-7 space-x-2">
<div className="space-x-2 mt-7">
<Button onClick={handleImport} variant="accent" size="sm">
Import
</Button>
@ -51,7 +53,7 @@ export default function OnboardingNewLibrary() {
placeholder={'e.g. "James\' Library"'}
/>
<div className="flex grow" />
<div className="mt-7 space-x-2">
<div className="space-x-2 mt-7">
<Button
type="submit"
variant="accent"

View file

@ -36,6 +36,7 @@ export type Platform = {
| { Ephemeral: { path: string } }
)[]
): Promise<unknown>;
requestFdaMacos?(): void;
getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
reloadWebview?(): Promise<unknown>;
getEphemeralFilesOpenWithApps?(paths: string[]): Promise<unknown>;

View file

@ -13,7 +13,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import prettier from 'prettier';
const assetFolders = ['icons', 'images', 'svgs/brands', 'svgs/ext/Extras', 'svgs/ext/Code'];
const assetFolders = ['icons', 'images', 'svgs/brands', 'svgs/ext/Extras', 'svgs/ext/Code', 'videos'];
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Binary file not shown.

View file

@ -0,0 +1,5 @@
import Fda from './fda.mp4';
export {
Fda
}