diff --git a/.changes/security-scoped-ios.md b/.changes/security-scoped-ios.md new file mode 100644 index 0000000000..0b7343491b --- /dev/null +++ b/.changes/security-scoped-ios.md @@ -0,0 +1,6 @@ +--- +"fs": minor +"fs-js": minor +--- + +Enable access for security-scoped resources on iOS by automatically calling `NSURL::startAccessingSecurityScopedResource` on resource access and adding the `stopAccessingSecurityScopedResource` API. diff --git a/Cargo.lock b/Cargo.lock index f7b3e66bef..cbce1f54c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6710,8 +6710,10 @@ dependencies = [ "anyhow", "dunce", "glob", + "log", "notify", "notify-debouncer-full", + "objc2-foundation 0.3.0", "percent-encoding", "schemars", "serde", diff --git a/plugins/fs/Cargo.toml b/plugins/fs/Cargo.toml index efb5737102..33ef5adbbe 100644 --- a/plugins/fs/Cargo.toml +++ b/plugins/fs/Cargo.toml @@ -30,6 +30,7 @@ serde_repr = "0.1" tauri = { workspace = true } thiserror = { workspace = true } url = { workspace = true } +log = { workspace = true } anyhow = "1" glob = { workspace = true } # TODO: Remove `serialization-compat-6` in v3 @@ -41,5 +42,8 @@ notify-debouncer-full = { version = "0.6", optional = true } dunce = { workspace = true } percent-encoding = "2" +[target.'cfg(target_os = "ios")'.dependencies] +objc2-foundation = { version = "0.3", features = ["NSURL", "NSString"] } + [features] watch = ["notify", "notify-debouncer-full"] diff --git a/plugins/fs/api-iife.js b/plugins/fs/api-iife.js index a392145df0..e3f745236c 100644 --- a/plugins/fs/api-iife.js +++ b/plugins/fs/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(t){"use strict";function e(t,e,n,i){if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?i:"a"===n?i.call(t):i?i.value:e.get(t)}function n(t,e,n,i,o){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,n),n}var i,o,r,a,s;"function"==typeof SuppressedError&&SuppressedError;const c="__TAURI_TO_IPC_KEY__";class f{constructor(t){i.set(this,void 0),o.set(this,0),r.set(this,[]),a.set(this,void 0),n(this,i,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}((t=>{const s=t.index;if("end"in t)return void(s==e(this,o,"f")?this.cleanupCallback():n(this,a,s));const c=t.message;if(s==e(this,o,"f")){for(e(this,i,"f").call(this,c),n(this,o,e(this,o,"f")+1);e(this,o,"f")in e(this,r,"f");){const t=e(this,r,"f")[e(this,o,"f")];e(this,i,"f").call(this,t),delete e(this,r,"f")[e(this,o,"f")],n(this,o,e(this,o,"f")+1)}e(this,o,"f")===e(this,a,"f")&&this.cleanupCallback()}else e(this,r,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){n(this,i,t)}get onmessage(){return e(this,i,"f")}[(i=new WeakMap,o=new WeakMap,r=new WeakMap,a=new WeakMap,c)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[c]()}}async function l(t,e={},n){return window.__TAURI_INTERNALS__.invoke(t,e,n)}class u{get rid(){return e(this,s,"f")}constructor(t){s.set(this,void 0),n(this,s,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}var p,d;function w(t){return{isFile:t.isFile,isDirectory:t.isDirectory,isSymlink:t.isSymlink,size:t.size,mtime:null!==t.mtime?new Date(t.mtime):null,atime:null!==t.atime?new Date(t.atime):null,birthtime:null!==t.birthtime?new Date(t.birthtime):null,readonly:t.readonly,fileAttributes:t.fileAttributes,dev:t.dev,ino:t.ino,mode:t.mode,nlink:t.nlink,uid:t.uid,gid:t.gid,rdev:t.rdev,blksize:t.blksize,blocks:t.blocks}}s=new WeakMap,t.BaseDirectory=void 0,(p=t.BaseDirectory||(t.BaseDirectory={}))[p.Audio=1]="Audio",p[p.Cache=2]="Cache",p[p.Config=3]="Config",p[p.Data=4]="Data",p[p.LocalData=5]="LocalData",p[p.Document=6]="Document",p[p.Download=7]="Download",p[p.Picture=8]="Picture",p[p.Public=9]="Public",p[p.Video=10]="Video",p[p.Resource=11]="Resource",p[p.Temp=12]="Temp",p[p.AppConfig=13]="AppConfig",p[p.AppData=14]="AppData",p[p.AppLocalData=15]="AppLocalData",p[p.AppCache=16]="AppCache",p[p.AppLog=17]="AppLog",p[p.Desktop=18]="Desktop",p[p.Executable=19]="Executable",p[p.Font=20]="Font",p[p.Home=21]="Home",p[p.Runtime=22]="Runtime",p[p.Template=23]="Template",t.SeekMode=void 0,(d=t.SeekMode||(t.SeekMode={}))[d.Start=0]="Start",d[d.Current=1]="Current",d[d.End=2]="End";class h extends u{async read(t){if(0===t.byteLength)return 0;const e=await l("plugin:fs|read",{rid:this.rid,len:t.byteLength}),n=function(t){const e=new Uint8ClampedArray(t),n=e.byteLength;let i=0;for(let t=0;tt instanceof URL?t.toString():t)),options:n,onEvent:o}),a=new g(r);return()=>{a.close()}}return t.FileHandle=h,t.copyFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol||e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|copy_file",{fromPath:t instanceof URL?t.toString():t,toPath:e instanceof URL?e.toString():e,options:n})},t.create=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|create",{path:t instanceof URL?t.toString():t,options:e});return new h(n)},t.exists=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|exists",{path:t instanceof URL?t.toString():t,options:e})},t.lstat=async function(t,e){return w(await l("plugin:fs|lstat",{path:t instanceof URL?t.toString():t,options:e}))},t.mkdir=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|mkdir",{path:t instanceof URL?t.toString():t,options:e})},t.open=y,t.readDir=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|read_dir",{path:t instanceof URL?t.toString():t,options:e})},t.readFile=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|read_file",{path:t instanceof URL?t.toString():t,options:e});return n instanceof ArrayBuffer?new Uint8Array(n):Uint8Array.from(n)},t.readTextFile=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|read_text_file",{path:t instanceof URL?t.toString():t,options:e}),i=n instanceof ArrayBuffer?n:Uint8Array.from(n);return new TextDecoder(e?.encoding??"utf-8").decode(i)},t.readTextFileLines=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=t instanceof URL?t.toString():t;return await Promise.resolve({path:n,rid:null,async next(){const t=new TextDecoder(e?.encoding??"utf-8");if(null===this.rid){const i=t.encoding;this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:null!=e?{...e,encoding:i}:void 0})}const i=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),o=i instanceof ArrayBuffer?new Uint8Array(i):Uint8Array.from(i),r=1===o[o.byteLength-1];if(r)return this.rid=null,{value:null,done:r};return{value:t.decode(o.slice(0,o.byteLength-1)),done:r}},[Symbol.asyncIterator](){return this}})},t.remove=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|remove",{path:t instanceof URL?t.toString():t,options:e})},t.rename=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol||e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|rename",{oldPath:t instanceof URL?t.toString():t,newPath:e instanceof URL?e.toString():e,options:n})},t.size=async function(t){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|size",{path:t instanceof URL?t.toString():t})},t.stat=async function(t,e){return w(await l("plugin:fs|stat",{path:t instanceof URL?t.toString():t,options:e}))},t.truncate=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|truncate",{path:t instanceof URL?t.toString():t,len:e,options:n})},t.watch=async function(t,e,n){return await L(t,e,{delayMs:2e3,...n})},t.watchImmediate=async function(t,e,n){return await L(t,e,{...n,delayMs:void 0})},t.writeFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");if(e instanceof ReadableStream){const i=await y(t,{read:!1,create:!0,write:!0,...n}),o=e.getReader();try{for(;;){const{done:t,value:e}=await o.read();if(t)break;await i.write(e)}}finally{o.releaseLock(),await i.close()}}else await l("plugin:fs|write_file",e,{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t.writeTextFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const i=new TextEncoder;await l("plugin:fs|write_text_file",i.encode(e),{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t}({});Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(t){"use strict";function e(t,e,n,i){if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?i:"a"===n?i.call(t):i?i.value:e.get(t)}function n(t,e,n,i,o){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,n),n}var i,o,r,a,s;"function"==typeof SuppressedError&&SuppressedError;const c="__TAURI_TO_IPC_KEY__";class f{constructor(t){i.set(this,void 0),o.set(this,0),r.set(this,[]),a.set(this,void 0),n(this,i,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}((t=>{const s=t.index;if("end"in t)return void(s==e(this,o,"f")?this.cleanupCallback():n(this,a,s));const c=t.message;if(s==e(this,o,"f")){for(e(this,i,"f").call(this,c),n(this,o,e(this,o,"f")+1);e(this,o,"f")in e(this,r,"f");){const t=e(this,r,"f")[e(this,o,"f")];e(this,i,"f").call(this,t),delete e(this,r,"f")[e(this,o,"f")],n(this,o,e(this,o,"f")+1)}e(this,o,"f")===e(this,a,"f")&&this.cleanupCallback()}else e(this,r,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){n(this,i,t)}get onmessage(){return e(this,i,"f")}[(i=new WeakMap,o=new WeakMap,r=new WeakMap,a=new WeakMap,c)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[c]()}}async function l(t,e={},n){return window.__TAURI_INTERNALS__.invoke(t,e,n)}class u{get rid(){return e(this,s,"f")}constructor(t){s.set(this,void 0),n(this,s,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}var p,d;function w(t){return{isFile:t.isFile,isDirectory:t.isDirectory,isSymlink:t.isSymlink,size:t.size,mtime:null!==t.mtime?new Date(t.mtime):null,atime:null!==t.atime?new Date(t.atime):null,birthtime:null!==t.birthtime?new Date(t.birthtime):null,readonly:t.readonly,fileAttributes:t.fileAttributes,dev:t.dev,ino:t.ino,mode:t.mode,nlink:t.nlink,uid:t.uid,gid:t.gid,rdev:t.rdev,blksize:t.blksize,blocks:t.blocks}}s=new WeakMap,t.BaseDirectory=void 0,(p=t.BaseDirectory||(t.BaseDirectory={}))[p.Audio=1]="Audio",p[p.Cache=2]="Cache",p[p.Config=3]="Config",p[p.Data=4]="Data",p[p.LocalData=5]="LocalData",p[p.Document=6]="Document",p[p.Download=7]="Download",p[p.Picture=8]="Picture",p[p.Public=9]="Public",p[p.Video=10]="Video",p[p.Resource=11]="Resource",p[p.Temp=12]="Temp",p[p.AppConfig=13]="AppConfig",p[p.AppData=14]="AppData",p[p.AppLocalData=15]="AppLocalData",p[p.AppCache=16]="AppCache",p[p.AppLog=17]="AppLog",p[p.Desktop=18]="Desktop",p[p.Executable=19]="Executable",p[p.Font=20]="Font",p[p.Home=21]="Home",p[p.Runtime=22]="Runtime",p[p.Template=23]="Template",t.SeekMode=void 0,(d=t.SeekMode||(t.SeekMode={}))[d.Start=0]="Start",d[d.Current=1]="Current",d[d.End=2]="End";class h extends u{async read(t){if(0===t.byteLength)return 0;const e=await l("plugin:fs|read",{rid:this.rid,len:t.byteLength}),n=function(t){const e=new Uint8ClampedArray(t),n=e.byteLength;let i=0;for(let t=0;tt instanceof URL?t.toString():t)),options:n,onEvent:o}),a=new g(r);return()=>{a.close()}}return t.FileHandle=h,t.copyFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol||e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|copy_file",{fromPath:t instanceof URL?t.toString():t,toPath:e instanceof URL?e.toString():e,options:n})},t.create=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|create",{path:t instanceof URL?t.toString():t,options:e});return new h(n)},t.exists=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|exists",{path:t instanceof URL?t.toString():t,options:e})},t.lstat=async function(t,e){return w(await l("plugin:fs|lstat",{path:t instanceof URL?t.toString():t,options:e}))},t.mkdir=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|mkdir",{path:t instanceof URL?t.toString():t,options:e})},t.open=y,t.readDir=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|read_dir",{path:t instanceof URL?t.toString():t,options:e})},t.readFile=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|read_file",{path:t instanceof URL?t.toString():t,options:e});return n instanceof ArrayBuffer?new Uint8Array(n):Uint8Array.from(n)},t.readTextFile=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=await l("plugin:fs|read_text_file",{path:t instanceof URL?t.toString():t,options:e}),i=n instanceof ArrayBuffer?n:Uint8Array.from(n);return new TextDecoder(e?.encoding??"utf-8").decode(i)},t.readTextFileLines=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const n=t instanceof URL?t.toString():t;return await Promise.resolve({path:n,rid:null,async next(){const t=new TextDecoder(e?.encoding??"utf-8");if(null===this.rid){const i=t.encoding;this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:null!=e?{...e,encoding:i}:void 0})}const i=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),o=i instanceof ArrayBuffer?new Uint8Array(i):Uint8Array.from(i),r=1===o[o.byteLength-1];if(r)return this.rid=null,{value:null,done:r};return{value:t.decode(o.slice(0,o.byteLength-1)),done:r}},[Symbol.asyncIterator](){return this}})},t.remove=async function(t,e){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|remove",{path:t instanceof URL?t.toString():t,options:e})},t.rename=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol||e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|rename",{oldPath:t instanceof URL?t.toString():t,newPath:e instanceof URL?e.toString():e,options:n})},t.size=async function(t){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");return await l("plugin:fs|size",{path:t instanceof URL?t.toString():t})},t.startAccessingSecurityScopedResource=async function(t){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|start_accessing_security_scoped_resource",{path:t instanceof URL?t.toString():t})},t.stat=async function(t,e){return w(await l("plugin:fs|stat",{path:t instanceof URL?t.toString():t,options:e}))},t.stopAccessingSecurityScopedResource=async function(t){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|stop_accessing_security_scoped_resource",{path:t instanceof URL?t.toString():t})},t.truncate=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await l("plugin:fs|truncate",{path:t instanceof URL?t.toString():t,len:e,options:n})},t.watch=async function(t,e,n){return await R(t,e,{delayMs:2e3,...n})},t.watchImmediate=async function(t,e,n){return await R(t,e,{...n,delayMs:void 0})},t.writeFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");if(e instanceof ReadableStream){const i=await y(t,{read:!1,create:!0,write:!0,...n}),o=e.getReader();try{for(;;){const{done:t,value:e}=await o.read();if(t)break;await i.write(e)}}finally{o.releaseLock(),await i.close()}}else await l("plugin:fs|write_file",e,{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t.writeTextFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const i=new TextEncoder;await l("plugin:fs|write_text_file",i.encode(e),{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t}({});Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} diff --git a/plugins/fs/build.rs b/plugins/fs/build.rs index 47e270034a..34b9475df6 100644 --- a/plugins/fs/build.rs +++ b/plugins/fs/build.rs @@ -104,6 +104,8 @@ const COMMANDS: &[(&str, &[&str])] = &[ // TODO: Remove this in v3 ("unwatch", &[]), ("size", &[]), + ("start_accessing_security_scoped_resource", &[]), + ("stop_accessing_security_scoped_resource", &[]), ]; fn main() { diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index 8a5d99752f..1bbe0ec6de 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -5,6 +5,19 @@ /** * Access the file system. * + * ## iOS security-scoped resources + * + * On iOS, the `fs` plugin automatically manages access to security-scoped resources when a file URL is accessed. + * This is required for files outside the app's sandbox (e.g., from file picker). + * + * @example + * ```typescript + * import { open } from '@tauri-apps/plugin-fs'; + * + * const file = await open('file:///path/to/file.txt'); + * await file.close(); + * ``` + * * ## Security * * This module prevents path traversal, not allowing parent directory accessors to be used @@ -1353,6 +1366,79 @@ async function size(path: string | URL): Promise { }) } +/** + * Starts accessing a security-scoped resource for the given file URL. + * This should be called when you're accessing a file that was opened + * using a security-scoped URL (e.g., from a file picker). + * + * Note that accessing security-scoped resources is automatically managed by the plugin on iOS, so you don't need to call this function + * unless you want to manage the scope manually. + * + * You must call {@linkcode stopAccessingSecurityScopedResource} when you're done accessing the resource. + * + * #### Platform-specific + * + * - **iOS:** Starts accessing the security-scoped resource. + * - **Other platforms:** does nothing. + * + * @example + * ```typescript + * import { startAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; + * + * const filePath = 'file:///path/to/file.txt'; + * await startAccessingSecurityScopedResource(filePath); + * // ... use the resource ... + * ``` + * + * @since 2.5.0 + */ +async function startAccessingSecurityScopedResource( + path: string | URL +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|start_accessing_security_scoped_resource', { + path: path instanceof URL ? path.toString() : path + }) +} + +/** + * Stops accessing a security-scoped resource for the given file URL. + * This should be called when you're done accessing a file that was opened + * using a security-scoped URL (e.g., from a file picker) when using manual tracking via {@linkcode startAccessingSecurityScopedResource}. + * + * #### Platform-specific + * + * - **iOS:** Stops accessing the security-scoped resource. + * - **Other platforms:** does nothing. + * + * @example + * ```typescript + * import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; + * + * const filePath = 'file:///path/to/file.txt'; + * await startAccessingSecurityScopedResource(filePath); + * // ... use the resource ... + * // when you're done with the resource: + * await stopAccessingSecurityScopedResource(filePath); + * ``` + * + * @since 2.5.0 + */ +async function stopAccessingSecurityScopedResource( + path: string | URL +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|stop_accessing_security_scoped_resource', { + path: path instanceof URL ? path.toString() : path + }) +} + export type { CreateOptions, OpenOptions, @@ -1401,5 +1487,7 @@ export { exists, watch, watchImmediate, - size + size, + startAccessingSecurityScopedResource, + stopAccessingSecurityScopedResource } diff --git a/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml b/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml new file mode 100644 index 0000000000..d14c502f3d --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-start-accessing-security-scoped-resource" +description = "Enables the start_accessing_security_scoped_resource command without any pre-configured scope." +commands.allow = ["start_accessing_security_scoped_resource"] + +[[permission]] +identifier = "deny-start-accessing-security-scoped-resource" +description = "Denies the start_accessing_security_scoped_resource command without any pre-configured scope." +commands.deny = ["start_accessing_security_scoped_resource"] diff --git a/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml b/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml new file mode 100644 index 0000000000..fae3f19cd4 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop-accessing-security-scoped-resource" +description = "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope." +commands.allow = ["stop_accessing_security_scoped_resource"] + +[[permission]] +identifier = "deny-stop-accessing-security-scoped-resource" +description = "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope." +commands.deny = ["stop_accessing_security_scoped_resource"] diff --git a/plugins/fs/permissions/autogenerated/reference.md b/plugins/fs/permissions/autogenerated/reference.md index 7f021a7f35..8aa25d5107 100644 --- a/plugins/fs/permissions/autogenerated/reference.md +++ b/plugins/fs/permissions/autogenerated/reference.md @@ -3435,6 +3435,32 @@ Denies the size command without any pre-configured scope. +`fs:allow-start-accessing-security-scoped-resource` + + + + +Enables the start_accessing_security_scoped_resource command without any pre-configured scope. + + + + + + + +`fs:deny-start-accessing-security-scoped-resource` + + + + +Denies the start_accessing_security_scoped_resource command without any pre-configured scope. + + + + + + + `fs:allow-stat` @@ -3461,6 +3487,32 @@ Denies the stat command without any pre-configured scope. +`fs:allow-stop-accessing-security-scoped-resource` + + + + +Enables the stop_accessing_security_scoped_resource command without any pre-configured scope. + + + + + + + +`fs:deny-stop-accessing-security-scoped-resource` + + + + +Denies the stop_accessing_security_scoped_resource command without any pre-configured scope. + + + + + + + `fs:allow-truncate` diff --git a/plugins/fs/permissions/schemas/schema.json b/plugins/fs/permissions/schemas/schema.json index e1c051f704..39db5e9c8c 100644 --- a/plugins/fs/permissions/schemas/schema.json +++ b/plugins/fs/permissions/schemas/schema.json @@ -1860,6 +1860,18 @@ "const": "deny-size", "markdownDescription": "Denies the size command without any pre-configured scope." }, + { + "description": "Enables the start_accessing_security_scoped_resource command without any pre-configured scope.", + "type": "string", + "const": "allow-start-accessing-security-scoped-resource", + "markdownDescription": "Enables the start_accessing_security_scoped_resource command without any pre-configured scope." + }, + { + "description": "Denies the start_accessing_security_scoped_resource command without any pre-configured scope.", + "type": "string", + "const": "deny-start-accessing-security-scoped-resource", + "markdownDescription": "Denies the start_accessing_security_scoped_resource command without any pre-configured scope." + }, { "description": "Enables the stat command without any pre-configured scope.", "type": "string", @@ -1872,6 +1884,18 @@ "const": "deny-stat", "markdownDescription": "Denies the stat command without any pre-configured scope." }, + { + "description": "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope.", + "type": "string", + "const": "allow-stop-accessing-security-scoped-resource", + "markdownDescription": "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope." + }, + { + "description": "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope.", + "type": "string", + "const": "deny-stop-accessing-security-scoped-resource", + "markdownDescription": "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope." + }, { "description": "Enables the truncate command without any pre-configured scope.", "type": "string", diff --git a/plugins/fs/src/mobile.rs b/plugins/fs/src/android.rs similarity index 65% rename from plugins/fs/src/mobile.rs rename to plugins/fs/src/android.rs index 472f2c8a96..54cd22ef5a 100644 --- a/plugins/fs/src/mobile.rs +++ b/plugins/fs/src/android.rs @@ -3,37 +3,31 @@ // SPDX-License-Identifier: MIT use serde::de::DeserializeOwned; -use tauri::{ - plugin::{PluginApi, PluginHandle}, - AppHandle, Runtime, -}; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; use crate::{models::*, FilePath, OpenOptions}; -#[cfg(target_os = "android")] const PLUGIN_IDENTIFIER: &str = "com.plugin.fs"; -#[cfg(target_os = "ios")] -tauri::ios_plugin_binding!(init_plugin_fs); +pub struct Fs(tauri::plugin::PluginHandle); -// initializes the Kotlin or Swift plugin classes pub fn init( _app: &AppHandle, api: PluginApi, ) -> crate::Result> { - #[cfg(target_os = "android")] let handle = api .register_android_plugin(PLUGIN_IDENTIFIER, "FsPlugin") .unwrap(); - #[cfg(target_os = "ios")] - let handle = api.register_ios_plugin(init_plugin_android - intent - send)?; Ok(Fs(handle)) } -/// Access to the android-intent-send APIs. -pub struct Fs(PluginHandle); - impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file. pub fn open>( &self, path: P, @@ -68,29 +62,25 @@ impl Fs { } } - #[cfg(target_os = "android")] fn resolve_content_uri( &self, uri: impl Into, mode: impl Into, ) -> crate::Result { - #[cfg(target_os = "android")] - { - let result = self.0.run_mobile_plugin::( - "getFileDescriptor", - GetFileDescriptorPayload { - uri: uri.into(), - mode: mode.into(), - }, - )?; - if let Some(fd) = result.fd { - Ok(unsafe { - use std::os::fd::FromRawFd; - std::fs::File::from_raw_fd(fd) - }) - } else { - unimplemented!() - } + let result = self.0.run_mobile_plugin::( + "getFileDescriptor", + GetFileDescriptorPayload { + uri: uri.into(), + mode: mode.into(), + }, + )?; + if let Some(fd) = result.fd { + Ok(unsafe { + use std::os::fd::FromRawFd; + std::fs::File::from_raw_fd(fd) + }) + } else { + unimplemented!() } } } diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index 93e861a209..6d53b85d5f 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -16,6 +16,7 @@ use std::{ borrow::Cow, fs::File, io::{BufRead, BufReader, Read, Write}, + ops::{Deref, DerefMut}, path::{Path, PathBuf}, str::FromStr, sync::Mutex, @@ -70,6 +71,209 @@ impl Serialize for CommandError { pub type CommandResult = std::result::Result; +/// Represents either a plain PathBuf or a PathHandle that manages security-scoped resources. +pub enum PathKind { + /// A plain path that doesn't manage security-scoped resources. + #[allow(dead_code)] // only used on mobile + Path(PathBuf), + /// A path handle that manages security-scoped resources and will clean them up on drop. + Handle(PathHandle), +} + +impl PathKind { + /// Get a reference to the underlying path. + pub fn as_path(&self) -> &Path { + match self { + PathKind::Path(p) => p.as_ref(), + PathKind::Handle(h) => h.as_ref(), + } + } + + /// Get a reference to the underlying PathBuf. + pub fn as_path_buf(&self) -> &PathBuf { + match self { + PathKind::Path(p) => p, + PathKind::Handle(h) => h, + } + } +} + +impl AsRef for PathKind { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl AsRef for PathKind { + fn as_ref(&self) -> &PathBuf { + self.as_path_buf() + } +} + +/// A file handle that automatically stops accessing security-scoped resources on iOS when dropped. +pub struct FileHandle { + file: File, + path: PathKind, + #[allow(dead_code)] // Used in Drop implementation + path_: SafeFilePath, + #[allow(dead_code)] // Used in Drop implementation + app_handle: tauri::AppHandle, +} + +impl FileHandle { + fn new( + file: File, + path: PathKind, + path_: SafeFilePath, + app_handle: tauri::AppHandle, + ) -> Self { + Self { + file, + path, + path_, + app_handle, + } + } + + /// Get the resolved path. + pub fn path(&self) -> &Path { + self.path.as_path() + } +} + +impl Deref for FileHandle { + type Target = File; + + fn deref(&self) -> &Self::Target { + &self.file + } +} + +impl DerefMut for FileHandle { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.file + } +} + +impl Drop for FileHandle { + fn drop(&mut self) { + #[cfg(target_os = "ios")] + { + // Only clean up if we have a plain PathBuf, not a PathHandle + // PathHandle will handle its own cleanup when it's dropped + if let PathKind::Path(_) = &self.path { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &self.path_ { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only clean up if we're tracking this resource + // If start_accessing_security_scoped_resource was used, it won't be in our tracking + // and we shouldn't interfere + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = + self.app_handle.state::(); + + // Only clean up if it's not tracked manually + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!("Stopping accessing security-scoped resource for URL: {url} on drop"); + let _ = self + .app_handle + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url( + url.clone(), + )); + security_scoped_resources.remove(url.as_str()); + } else { + log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); + } + } + } + } + } + } +} + +/// A path handle that automatically stops accessing security-scoped resources on iOS when dropped. +pub struct PathHandle { + path: PathBuf, + #[allow(dead_code)] // Used in Drop implementation + path_: SafeFilePath, + #[allow(dead_code)] // Used in Drop implementation + app_handle: tauri::AppHandle, +} + +impl PathHandle { + fn new(path: PathBuf, path_: SafeFilePath, app_handle: tauri::AppHandle) -> Self { + Self { + path, + path_, + app_handle, + } + } +} + +impl Deref for PathHandle { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.path + } +} + +impl AsRef for PathHandle { + fn as_ref(&self) -> &Path { + self.path.as_ref() + } +} + +impl AsRef for PathHandle { + fn as_ref(&self) -> &PathBuf { + &self.path + } +} + +impl Drop for PathHandle { + fn drop(&mut self) { + #[cfg(target_os = "ios")] + { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &self.path_ { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only clean up if we're tracking this resource (i.e., resolve_path started it) + // If start_accessing_security_scoped_resource was used, it won't be in our tracking + // and we shouldn't interfere + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = + self.app_handle.state::(); + + // Only clean up if it's not tracked manually + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Stopping accessing security-scoped resource for URL: {url} on drop" + ); + let _ = self + .app_handle + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url(url.clone())); + security_scoped_resources.remove(url.as_str()); + } else { + log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); + } + } + } + } + } +} + #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BaseOptions { @@ -84,7 +288,8 @@ pub fn create( path: SafeFilePath, options: Option, ) -> CommandResult { - let resolved_path = resolve_path( + let path_ = path.clone(); + let resolved_path_handle = resolve_path( "create", &webview, &global_scope, @@ -92,13 +297,22 @@ pub fn create( path, options.and_then(|o| o.base_dir), )?; - let file = File::create(&resolved_path).map_err(|e| { + let file = File::create(&*resolved_path_handle).map_err(|e| { format!( "failed to create file at path: {} with error: {e}", - resolved_path.display() + resolved_path_handle.display() ) })?; - let rid = webview.resources_table().add(StdFileResource::new(file)); + let app_handle = webview.app_handle().clone(); + let file_handle = FileHandle::new( + file, + PathKind::Handle(resolved_path_handle), + path_, + app_handle, + ); + let rid = webview + .resources_table() + .add(StdFileResource::new(file_handle)); Ok(rid) } @@ -119,7 +333,7 @@ pub fn open( path: SafeFilePath, options: Option, ) -> CommandResult { - let (file, _path) = resolve_file( + let file_handle = resolve_file( "open", &webview, &global_scope, @@ -147,7 +361,9 @@ pub fn open( }, )?; - let rid = webview.resources_table().add(StdFileResource::new(file)); + let rid = webview + .resources_table() + .add(StdFileResource::new(file_handle)); Ok(rid) } @@ -308,8 +524,8 @@ pub async fn read( len: usize, ) -> CommandResult { let mut data = vec![0; len]; - let file = webview.resources_table().get::(rid)?; - let nread = StdFileResource::with_lock(&file, |mut file| file.read(&mut data)) + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + let nread = StdFileResource::with_lock(&file, |file| file.read(&mut data)) .map_err(|e| format!("faied to read bytes from file with error: {e}"))?; // This is an optimization to include the number of read bytes (as bigendian bytes) @@ -345,7 +561,7 @@ async fn read_file_inner( path: SafeFilePath, options: Option, ) -> CommandResult { - let (mut file, path) = resolve_file( + let mut file_handle = resolve_file( permission, &webview, &global_scope, @@ -364,10 +580,10 @@ async fn read_file_inner( let mut contents = Vec::new(); - file.read_to_end(&mut contents).map_err(|e| { + file_handle.read_to_end(&mut contents).map_err(|e| { format!( "failed to read file as text at path: {} with error: {e}", - path.display() + file_handle.path().display() ) })?; @@ -638,8 +854,8 @@ pub async fn seek( whence: SeekMode, ) -> CommandResult { use std::io::{Seek, SeekFrom}; - let file = webview.resources_table().get::(rid)?; - StdFileResource::with_lock(&file, |mut file| { + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + StdFileResource::with_lock(&file, |file| { file.seek(match whence { SeekMode::Start => SeekFrom::Start(offset as u64), SeekMode::Current => SeekFrom::Current(offset), @@ -662,7 +878,7 @@ fn get_metadata std::io::Result CommandResult { match path { SafeFilePath::Url(url) => { - let (file, path) = resolve_file( + let file_handle = resolve_file( permission, webview, global_scope, @@ -676,10 +892,10 @@ fn get_metadata std::io::Result( #[tauri::command] pub fn fstat(webview: Webview, rid: ResourceId) -> CommandResult { - let file = webview.resources_table().get::(rid)?; + let file: std::sync::Arc> = webview.resources_table().get(rid)?; let metadata = StdFileResource::with_lock(&file, |file| file.metadata()) .map_err(|e| format!("failed to get metadata of file with error: {e}"))?; Ok(get_stat(metadata)) @@ -834,7 +1050,7 @@ pub async fn ftruncate( rid: ResourceId, len: Option, ) -> CommandResult<()> { - let file = webview.resources_table().get::(rid)?; + let file: std::sync::Arc> = webview.resources_table().get(rid)?; StdFileResource::with_lock(&file, |file| file.set_len(len.unwrap_or(0))) .map_err(|e| format!("failed to truncate file with error: {e}")) .map_err(Into::into) @@ -846,8 +1062,8 @@ pub async fn write( rid: ResourceId, data: Vec, ) -> CommandResult { - let file = webview.resources_table().get::(rid)?; - StdFileResource::with_lock(&file, |mut file| file.write(&data)) + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + StdFileResource::with_lock(&file, |file| file.write(&data)) .map_err(|e| format!("failed to write bytes to file with error: {e}")) .map_err(Into::into) } @@ -895,7 +1111,7 @@ async fn write_file_inner( .and_then(|p| p.to_str().ok()) .and_then(|opts| serde_json::from_str(opts).ok()); - let (mut file, path) = resolve_file( + let mut file_handle = resolve_file( permission, &webview, &global_scope, @@ -942,11 +1158,12 @@ async fn write_file_inner( _ => return Err(anyhow::anyhow!("unexpected invoke body").into()), }; - file.write_all(&data) + file_handle + .write_all(&data) .map_err(|e| { format!( "failed to write bytes to file at path: {} with error: {e}", - path.display() + file_handle.path().display() ) }) .map_err(Into::into) @@ -1032,6 +1249,130 @@ pub async fn size( } } +#[tauri::command] +pub fn start_accessing_security_scoped_resource( + webview: Webview, + path: SafeFilePath, +) -> CommandResult<()> { + #[cfg(target_os = "ios")] + { + use crate::FilePath; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &path { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only handle file URLs + if let FilePath::Url(url) = &file_path { + if url.scheme() == "file" { + use objc2_foundation::{NSString, NSURL}; + + let url_nsstring = NSString::from_str(url.as_str()); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Check if already active + let security_scoped_resources = + webview.state::(); + if security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Security-scoped resource already active for URL: {}", + url.as_str() + ); + return Ok(()); + } + + // Start accessing the security-scoped resource + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!( + "Started accessing security-scoped resource for URL: {}", + url.as_str() + ); + security_scoped_resources.track_manually(url.as_str().to_string()); + } else { + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url.as_str() + ); + return Err(CommandError::from(format!( + "Failed to start accessing security-scoped resource for URL: {}", + url.as_str() + ))); + } + } + } else { + return Err(CommandError::from(format!( + "Failed to create NSURL from URL: {}", + url.as_str() + ))); + } + } + } + Ok(()) + } + #[cfg(not(target_os = "ios"))] + { + // No-op on non-iOS platforms + let _ = webview; + let _ = path; + Ok(()) + } +} + +#[tauri::command] +pub fn stop_accessing_security_scoped_resource( + webview: Webview, + path: SafeFilePath, +) -> CommandResult<()> { + #[cfg(target_os = "ios")] + { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &path { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only handle file URLs + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = webview.state::(); + + // Check if it's tracked + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Security-scoped resource not tracked as active for URL: {}", + url.as_str() + ); + return Ok(()); + } + + // Stop accessing the security-scoped resource + webview + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url(url.clone()))?; + + // Remove from tracking + security_scoped_resources.remove(url.as_str()); + log::debug!( + "Stopped accessing security-scoped resource for URL: {}", + url.as_str() + ); + } + } + Ok(()) + } + #[cfg(not(target_os = "ios"))] + { + // No-op on non-iOS platforms + let _ = webview; + let _ = path; + Ok(()) + } +} + fn get_dir_size(path: &PathBuf) -> CommandResult { let mut size = 0; @@ -1049,7 +1390,7 @@ fn get_dir_size(path: &PathBuf) -> CommandResult { Ok(size) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub fn resolve_file( permission: &str, webview: &Webview, @@ -1057,7 +1398,7 @@ pub fn resolve_file( command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, -) -> CommandResult<(File, PathBuf)> { +) -> CommandResult> { resolve_file_in_fs( permission, webview, @@ -1075,8 +1416,9 @@ fn resolve_file_in_fs( command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, -) -> CommandResult<(File, PathBuf)> { - let path = resolve_path( +) -> CommandResult> { + let path_ = path.clone(); + let resolved_path_handle = resolve_path( permission, webview, global_scope, @@ -1086,17 +1428,24 @@ fn resolve_file_in_fs( )?; let file = std::fs::OpenOptions::from(open_options.options) - .open(&path) + .open(&*resolved_path_handle) .map_err(|e| { format!( "failed to open file at path: {} with error: {e}", - path.display() + resolved_path_handle.display() ) })?; - Ok((file, path)) + + let app_handle = webview.app_handle().clone(); + Ok(FileHandle::new( + file, + PathKind::Handle(resolved_path_handle), + path_, + app_handle, + )) } -#[cfg(target_os = "android")] +#[cfg(mobile)] pub fn resolve_file( permission: &str, webview: &Webview, @@ -1104,16 +1453,23 @@ pub fn resolve_file( command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, -) -> CommandResult<(File, PathBuf)> { +) -> CommandResult> { use crate::FsExt; + let path_ = path.clone(); match path { SafeFilePath::Url(url) => { - let path = url.as_str().into(); + let resolved_path = url.as_str().into(); let file = webview .fs() - .open(SafeFilePath::Url(url), open_options.options)?; - Ok((file, path)) + .open(SafeFilePath::Url(url.clone()), open_options.options)?; + let app_handle = webview.app_handle().clone(); + Ok(FileHandle::new( + file, + PathKind::Path(resolved_path), + path_, + app_handle, + )) } SafeFilePath::Path(path) => resolve_file_in_fs( permission, @@ -1133,9 +1489,47 @@ pub fn resolve_path( command_scope: &CommandScope, path: SafeFilePath, base_dir: Option, -) -> CommandResult { +) -> CommandResult> { + let path_ = path.clone(); + // On iOS, start accessing security-scoped resource if the path is a file URL + // Only if it hasn't been started already via start_accessing_security_scoped_resource + #[cfg(target_os = "ios")] + { + if let SafeFilePath::Url(url) = &path { + if url.scheme() == "file" { + use objc2_foundation::{NSString, NSURL}; + + let security_scoped_resources = webview.state::(); + + // Check if already active (started via start_accessing_security_scoped_resource) + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + let url_nsstring = NSString::from_str(url.as_str()); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Start accessing the security-scoped resource + // This is required for files outside the app's sandbox (e.g., from file picker) + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!("Started accessing security-scoped resource for URL: {} (via resolve_path)", url.as_str()); + // Track it so we know to clean it up + security_scoped_resources.track_manually(url.as_str().to_string()); + } else { + log::warn!("Failed to start accessing security-scoped resource for URL: {}", url.as_str()); + } + } + } else { + log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url.as_str()); + } + } else { + log::debug!("Security-scoped resource already active for URL: {} (started via start_accessing_security_scoped_resource), skipping", url.as_str()); + } + } + } + } + let path = path.into_path()?; - let path = if let Some(base_dir) = base_dir { + let resolved_path = if let Some(base_dir) = base_dir { webview.path().resolve(&path, base_dir)? } else { path @@ -1164,23 +1558,24 @@ pub fn resolve_path( let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix)); - if is_forbidden(&fs_scope.scope, &path, require_literal_leading_dot) - || is_forbidden(&scope, &path, require_literal_leading_dot) + if is_forbidden(&fs_scope.scope, &resolved_path, require_literal_leading_dot) + || is_forbidden(&scope, &resolved_path, require_literal_leading_dot) { - return Err(CommandError::Plugin(Error::PathForbidden(path))); + return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); } - if fs_scope.scope.is_allowed(&path) || scope.is_allowed(&path) { - Ok(path) + if fs_scope.scope.is_allowed(&resolved_path) || scope.is_allowed(&resolved_path) { + let app_handle = webview.app_handle().clone(); + Ok(PathHandle::new(resolved_path, path_, app_handle)) } else { #[cfg(not(debug_assertions))] - return Err(CommandError::Plugin(Error::PathForbidden(path))); + return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); #[cfg(debug_assertions)] Err( anyhow::anyhow!( "forbidden path: {}, maybe it is not allowed on the scope for `allow-{permission}` permission in your capability file", - path.display() + resolved_path.display() ) ) .map_err(Into::into) @@ -1226,20 +1621,20 @@ fn is_forbidden>( } } -struct StdFileResource(Mutex); +struct StdFileResource(Mutex>); -impl StdFileResource { - fn new(file: File) -> Self { - Self(Mutex::new(file)) +impl StdFileResource { + fn new(file_handle: FileHandle) -> Self { + Self(Mutex::new(file_handle)) } - fn with_lock R>(&self, mut f: F) -> R { - let file = self.0.lock().unwrap(); - f(&file) + fn with_lock Ret>(&self, mut f: F) -> Ret { + let mut file_handle = self.0.lock().unwrap(); + f(&mut file_handle) } } -impl Resource for StdFileResource {} +impl Resource for StdFileResource {} /// Same as [std::io::Lines] but with bytes struct LinesBytes { diff --git a/plugins/fs/src/desktop.rs b/plugins/fs/src/desktop.rs index 477c053760..1dc77fd700 100644 --- a/plugins/fs/src/desktop.rs +++ b/plugins/fs/src/desktop.rs @@ -24,6 +24,12 @@ fn path_or_err>(p: P) -> std::io::Result { } impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file. pub fn open>( &self, path: P, diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs new file mode 100644 index 0000000000..c4a4335427 --- /dev/null +++ b/plugins/fs/src/ios.rs @@ -0,0 +1,137 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::{FilePath, OpenOptions}; + +pub struct Fs { + _phantom: std::marker::PhantomData R>, +} + +pub fn init( + _app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Fs { + _phantom: std::marker::PhantomData, + }) +} + +impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call [`Self::stop_accessing_security_scoped_resource`] when you're done accessing the file. + pub fn open>( + &self, + path: P, + opts: OpenOptions, + ) -> std::io::Result { + use objc2_foundation::{NSString, NSURL}; + + match path.into() { + FilePath::Url(url) if url.scheme() == "file" => { + // Handle security-scoped URLs on iOS + let url_string = url.as_str(); + let url_nsstring = NSString::from_str(url_string); + + // Create NSURL from the URL string + // URLWithString may return None for invalid URLs, but file:// URLs should be valid + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Start accessing the security-scoped resource + // This is required for files outside the app's sandbox (e.g., from file picker) + // Note: We don't call stopAccessingSecurityScopedResource here because + // the file handle needs to remain accessible while the File is in use. + // The access will be automatically stopped when the app is backgrounded or terminated. + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!( + "Started accessing security-scoped resource for URL: {}", + url_string + ); + } else { + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url_string + ); + } + } + } else { + log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url_string); + } + + // Convert URL to path and open the file + let path = url.to_file_path().map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file URL") + })?; + std::fs::OpenOptions::from(opts).open(path) + } + FilePath::Url(_) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot use a non-file URL to load files on iOS", + )), + FilePath::Path(p) => { + // Regular path, no security-scoped resource handling needed + std::fs::OpenOptions::from(opts).open(p) + } + } + } + + /// Stops accessing a security-scoped resource for the given file path or URL. + /// This should be called when you're done accessing a file that was opened + /// using a security-scoped URL (e.g., from a file picker). + /// + /// # Arguments + /// + /// * `path` - A file path or URL that was previously accessed via `startAccessingSecurityScopedResource` + /// + /// # Returns + /// + /// Returns `Ok(())` if successful, or an error if the path/URL is invalid or not a file URL. + pub fn stop_accessing_security_scoped_resource>( + &self, + path: P, + ) -> crate::Result<()> { + use objc2_foundation::{NSString, NSURL}; + + let file_path = path.into(); + let url_string = match file_path { + FilePath::Url(url) => { + if url.scheme() != "file" { + return Err(crate::Error::InvalidPathUrl); + } + url.as_str().to_string() + } + FilePath::Path(p) => { + // Convert path to file URL + url::Url::from_file_path(&p) + .map_err(|_| crate::Error::InvalidPathUrl)? + .as_str() + .to_string() + } + }; + + let url_nsstring = NSString::from_str(&url_string); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Stop accessing the security-scoped resource + unsafe { + ns_url.stopAccessingSecurityScopedResource(); + } + } else { + return Err(crate::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "failed to create NSURL from URL", + ))); + } + + Ok(()) + } +} diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index bdc6b17099..05fe675513 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -4,12 +4,17 @@ //! Access the file system. +// TODO(v3): consider redesign the API to implement automatic stopAccessingSecurityScopedResource on iOS +// this likely requires returning a handle to a resource so we can impl Drop for it + #![doc( html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" )] use std::io::Read; +#[cfg(target_os = "ios")] +use std::sync::Mutex; use serde::Deserialize; use tauri::{ @@ -19,24 +24,28 @@ use tauri::{ AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, }; +#[cfg(target_os = "android")] +mod android; mod commands; mod config; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] mod desktop; mod error; mod file_path; -#[cfg(target_os = "android")] -mod mobile; +#[cfg(target_os = "ios")] +mod ios; #[cfg(target_os = "android")] mod models; mod scope; #[cfg(feature = "watch")] mod watcher; -#[cfg(not(target_os = "android"))] -pub use desktop::Fs; #[cfg(target_os = "android")] -pub use mobile::Fs; +pub use android::Fs; +#[cfg(desktop)] +pub use desktop::Fs; +#[cfg(target_os = "ios")] +pub use ios::Fs; pub use error::Error; @@ -369,6 +378,56 @@ pub(crate) struct Scope { pub(crate) require_literal_leading_dot: Option, } +/// Tracks which paths have active security-scoped resource access on iOS. +#[cfg(target_os = "ios")] +pub(crate) struct SecurityScopedResources { + /// Set of file URLs that are currently accessing security-scoped resources. + /// The key is the URL string representation. + pub(crate) active_urls: Mutex>, +} + +#[cfg(target_os = "ios")] +impl SecurityScopedResources { + pub(crate) fn new() -> Self { + Self { + active_urls: Mutex::new(std::collections::HashSet::new()), + } + } + + pub(crate) fn is_tracked_manually(&self, url: &str) -> bool { + self.active_urls.lock().unwrap().contains(url) + } + + pub(crate) fn track_manually(&self, url: String) { + self.active_urls.lock().unwrap().insert(url); + } + + pub(crate) fn remove(&self, url: &str) { + self.active_urls.lock().unwrap().remove(url); + } +} + +#[cfg(not(target_os = "ios"))] +pub(crate) struct SecurityScopedResources; + +#[cfg(not(target_os = "ios"))] +impl SecurityScopedResources { + pub(crate) fn new() -> Self { + Self + } + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn is_tracked_manually(&self, _url: &str) -> bool { + false + } + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn track_manually(&self, _url: String) {} + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn remove(&self, _url: &str) {} +} + pub trait FsExt { fn fs_scope(&self) -> tauri::fs::Scope; fn try_fs_scope(&self) -> Option; @@ -417,6 +476,8 @@ pub fn init() -> TauriPlugin> { commands::write_text_file, commands::exists, commands::size, + commands::start_accessing_security_scoped_resource, + commands::stop_accessing_security_scoped_resource, #[cfg(feature = "watch")] watcher::watch, ]) @@ -431,13 +492,19 @@ pub fn init() -> TauriPlugin> { #[cfg(target_os = "android")] { - let fs = mobile::init(app, api)?; + let fs = android::init(app, api)?; app.manage(fs); } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let fs = ios::init(app, api)?; + app.manage(fs); + } + #[cfg(desktop)] app.manage(Fs(app.clone())); app.manage(scope); + app.manage(SecurityScopedResources::new()); Ok(()) }) .on_event(|app, event| {