From 867eeed932a19d95de29a70e2d4806d2d2eace88 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 29 Dec 2025 07:26:54 -0300 Subject: [PATCH 01/11] feat(fs): access security scoped resources on iOS --- Cargo.lock | 1 + plugins/fs/Cargo.toml | 3 + plugins/fs/api-iife.js | 2 +- plugins/fs/build.rs | 1 + plugins/fs/guest-js/index.ts | 35 ++++- ...op_accessing_security_scoped_resource.toml | 13 ++ .../fs/permissions/autogenerated/reference.md | 26 ++++ plugins/fs/permissions/schemas/schema.json | 12 ++ plugins/fs/src/{mobile.rs => android.rs} | 48 +++---- plugins/fs/src/commands.rs | 50 ++++++- plugins/fs/src/ios.rs | 122 ++++++++++++++++++ plugins/fs/src/lib.rs | 24 +++- 12 files changed, 296 insertions(+), 41 deletions(-) create mode 100644 plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml rename plugins/fs/src/{mobile.rs => android.rs} (65%) create mode 100644 plugins/fs/src/ios.rs diff --git a/Cargo.lock b/Cargo.lock index 20f51b205f..764b475cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6626,6 +6626,7 @@ dependencies = [ "glob", "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 d38cab5b2c..0e0256e12e 100644 --- a/plugins/fs/Cargo.toml +++ b/plugins/fs/Cargo.toml @@ -41,5 +41,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 aabe9f3328..e3481bbc14 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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 L(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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__})} +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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 R(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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__})} diff --git a/plugins/fs/build.rs b/plugins/fs/build.rs index 47e270034a..928df1cdf4 100644 --- a/plugins/fs/build.rs +++ b/plugins/fs/build.rs @@ -104,6 +104,7 @@ const COMMANDS: &[(&str, &[&str])] = &[ // TODO: Remove this in v3 ("unwatch", &[]), ("size", &[]), + ("stop_accessing_security_scoped_resource", &[]), ]; fn main() { diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index c4be8d4acb..eb28fc521f 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -1348,6 +1348,38 @@ async function size(path: string | URL): Promise { }) } +/** + * 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). + * + * #### Platform-specific + * + * - **iOS:** Stops accessing the security-scoped resource. + * - **Other platforms:** No-op (does nothing). + * + * @example + * ```typescript + * import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; + * + * // After you're done with a file from a file picker + * await stopAccessingSecurityScopedResource('file:///path/to/file.txt'); + * ``` + * + * @since 2.4.4 + */ +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, @@ -1396,5 +1428,6 @@ export { exists, watch, watchImmediate, - size + size, + stopAccessingSecurityScopedResource } 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..2c3f7bf7a4 100644 --- a/plugins/fs/permissions/autogenerated/reference.md +++ b/plugins/fs/permissions/autogenerated/reference.md @@ -3461,6 +3461,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..9fca1a9482 100644 --- a/plugins/fs/permissions/schemas/schema.json +++ b/plugins/fs/permissions/schemas/schema.json @@ -1872,6 +1872,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..ab109a34f2 100644 --- a/plugins/fs/src/mobile.rs +++ b/plugins/fs/src/android.rs @@ -4,35 +4,26 @@ use serde::de::DeserializeOwned; use tauri::{ - plugin::{PluginApi, PluginHandle}, + plugin::PluginApi, AppHandle, Runtime, }; -use crate::{models::*, FilePath, OpenOptions}; +use crate::{FilePath, OpenOptions, models::*}; -#[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 { pub fn open>( &self, @@ -68,29 +59,26 @@ 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 ad837a4c0d..e6fe77d50e 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -1010,7 +1010,32 @@ fn get_dir_size(path: &PathBuf) -> CommandResult { Ok(size) } -#[cfg(not(target_os = "android"))] +#[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), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + webview.fs().stop_accessing_security_scoped_resource(file_path)?; + Ok(()) + } + #[cfg(not(target_os = "ios"))] + { + // No-op on non-iOS platforms + let _ = webview; + let _ = path; + Ok(()) + } +} + +#[cfg(desktop)] pub fn resolve_file( permission: &str, webview: &Webview, @@ -1057,7 +1082,7 @@ fn resolve_file_in_fs( Ok((file, path)) } -#[cfg(target_os = "android")] +#[cfg(mobile)] pub fn resolve_file( permission: &str, webview: &Webview, @@ -1095,6 +1120,27 @@ pub fn resolve_path( path: SafeFilePath, base_dir: Option, ) -> CommandResult { + // On iOS, start accessing security-scoped resource if the path is a file URL + #[cfg(target_os = "ios")] + { + if let SafeFilePath::Url(url) = &path { + if url.scheme() == "file" { + use objc2_foundation::{NSString, NSURL}; + + let url_string = url.as_str(); + let url_nsstring = NSString::from_str(url_string); + 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 _ = ns_url.startAccessingSecurityScopedResource(); + } + } + } + } + } + let path = path.into_path()?; let path = if let Some(base_dir) = base_dir { webview.path().resolve(&path, base_dir)? diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs new file mode 100644 index 0000000000..5cfe5a1cd0 --- /dev/null +++ b/plugins/fs/src/ios.rs @@ -0,0 +1,122 @@ +// 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 { + 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 _ = ns_url.startAccessingSecurityScopedResource(); + } + } + + // 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(); + } + } + + Ok(()) + } +} + diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index bdc6b17099..dc8a8a7c6c 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -21,22 +21,26 @@ use tauri::{ mod commands; mod config; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] mod desktop; +#[cfg(target_os = "android")] +mod android; +#[cfg(target_os = "ios")] +mod ios; mod error; mod file_path; #[cfg(target_os = "android")] -mod mobile; -#[cfg(target_os = "android")] mod models; mod scope; #[cfg(feature = "watch")] mod watcher; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub use desktop::Fs; #[cfg(target_os = "android")] -pub use mobile::Fs; +pub use android::Fs; +#[cfg(target_os = "ios")] +pub use ios::Fs; pub use error::Error; @@ -417,6 +421,7 @@ pub fn init() -> TauriPlugin> { commands::write_text_file, commands::exists, commands::size, + commands::stop_accessing_security_scoped_resource, #[cfg(feature = "watch")] watcher::watch, ]) @@ -431,10 +436,15 @@ 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); From 312acb60b33fb194f75ba2c409817e34be6501a1 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 29 Dec 2025 08:04:49 -0300 Subject: [PATCH 02/11] fmt --- plugins/fs/src/android.rs | 8 ++------ plugins/fs/src/commands.rs | 6 ++++-- plugins/fs/src/ios.rs | 19 +++++-------------- plugins/fs/src/lib.rs | 12 ++++++------ 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/plugins/fs/src/android.rs b/plugins/fs/src/android.rs index ab109a34f2..975bd465a6 100644 --- a/plugins/fs/src/android.rs +++ b/plugins/fs/src/android.rs @@ -3,12 +3,9 @@ // SPDX-License-Identifier: MIT use serde::de::DeserializeOwned; -use tauri::{ - plugin::PluginApi, - AppHandle, Runtime, -}; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; -use crate::{FilePath, OpenOptions, models::*}; +use crate::{models::*, FilePath, OpenOptions}; const PLUGIN_IDENTIFIER: &str = "com.plugin.fs"; @@ -81,4 +78,3 @@ impl Fs { } } } - diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index e6fe77d50e..17f412b60c 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -1023,7 +1023,9 @@ pub fn stop_accessing_security_scoped_resource( SafeFilePath::Url(url) => FilePath::Url(url), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; - webview.fs().stop_accessing_security_scoped_resource(file_path)?; + webview + .fs() + .stop_accessing_security_scoped_resource(file_path)?; Ok(()) } #[cfg(not(target_os = "ios"))] @@ -1126,7 +1128,7 @@ pub fn resolve_path( if let SafeFilePath::Url(url) = &path { if url.scheme() == "file" { use objc2_foundation::{NSString, NSURL}; - + let url_string = url.as_str(); let url_nsstring = NSString::from_str(url_string); let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs index 5cfe5a1cd0..05f3c660b9 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -3,10 +3,7 @@ // SPDX-License-Identifier: MIT use serde::de::DeserializeOwned; -use tauri::{ - plugin::PluginApi, - AppHandle, Runtime, -}; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; use crate::{FilePath, OpenOptions}; @@ -36,7 +33,7 @@ impl Fs { // 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) }; @@ -52,14 +49,9 @@ impl Fs { } // 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", - ) - })?; + 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( @@ -119,4 +111,3 @@ impl Fs { Ok(()) } } - diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index dc8a8a7c6c..9a87e28478 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -19,26 +19,26 @@ use tauri::{ AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, }; +#[cfg(target_os = "android")] +mod android; mod commands; mod config; #[cfg(desktop)] mod desktop; -#[cfg(target_os = "android")] -mod android; -#[cfg(target_os = "ios")] -mod ios; mod error; mod file_path; +#[cfg(target_os = "ios")] +mod ios; #[cfg(target_os = "android")] mod models; mod scope; #[cfg(feature = "watch")] mod watcher; -#[cfg(desktop)] -pub use desktop::Fs; #[cfg(target_os = "android")] pub use android::Fs; +#[cfg(desktop)] +pub use desktop::Fs; #[cfg(target_os = "ios")] pub use ios::Fs; From b1a73f41fd996d8e832d2407e07c61ffb9c2bcb8 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 29 Dec 2025 13:20:47 -0300 Subject: [PATCH 03/11] add change file --- .changes/security-scoped-ios.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/security-scoped-ios.md diff --git a/.changes/security-scoped-ios.md b/.changes/security-scoped-ios.md new file mode 100644 index 0000000000..9ba7810837 --- /dev/null +++ b/.changes/security-scoped-ios.md @@ -0,0 +1,6 @@ +--- +"fs": patch +"fs-js": patch +--- + +Enable access for security-scoped resources on iOS by automatically calling `NSURL::startAccessingSecurityScopedResource` on resource access and adding the `stopAccessingSecurityScopedResource` API. From c3411ed83ebf4333940f537933f690d771452308 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 09:40:33 -0300 Subject: [PATCH 04/11] add logs and error handling --- Cargo.lock | 1 + plugins/fs/Cargo.toml | 1 + plugins/fs/src/commands.rs | 7 ++++++- plugins/fs/src/ios.rs | 9 ++++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 764b475cc7..784d80a5e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6624,6 +6624,7 @@ dependencies = [ "anyhow", "dunce", "glob", + "log", "notify", "notify-debouncer-full", "objc2-foundation 0.3.0", diff --git a/plugins/fs/Cargo.toml b/plugins/fs/Cargo.toml index 0e0256e12e..ef7d352177 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 diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index 17f412b60c..3c159d34c1 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -1136,8 +1136,13 @@ pub fn resolve_path( // Start accessing the security-scoped resource // This is required for files outside the app's sandbox (e.g., from file picker) unsafe { - let _ = ns_url.startAccessingSecurityScopedResource(); + let success = ns_url.startAccessingSecurityScopedResource(); + if !success { + 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()); } } } diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs index 05f3c660b9..0894dfc69d 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -44,8 +44,13 @@ impl Fs { // 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 _ = ns_url.startAccessingSecurityScopedResource(); + let success = ns_url.startAccessingSecurityScopedResource(); + if !success { + 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 @@ -106,6 +111,8 @@ impl Fs { 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(()) From 48e5e75d18b681cfaebb0c3929fdc032d73f4646 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 09:41:02 -0300 Subject: [PATCH 05/11] update docs --- plugins/fs/src/android.rs | 6 ++++++ plugins/fs/src/desktop.rs | 6 ++++++ plugins/fs/src/ios.rs | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/plugins/fs/src/android.rs b/plugins/fs/src/android.rs index 975bd465a6..54cd22ef5a 100644 --- a/plugins/fs/src/android.rs +++ b/plugins/fs/src/android.rs @@ -22,6 +22,12 @@ pub fn init( } 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/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 index 0894dfc69d..11eccf1b08 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -21,6 +21,12 @@ pub fn init( } 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, From 81dad6f601dc99dd913a7d2cf53d8922f2d74dc9 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 10:07:54 -0300 Subject: [PATCH 06/11] automatic stop accessing security scoped resources --- plugins/fs/api-iife.js | 2 +- plugins/fs/build.rs | 1 - plugins/fs/guest-js/index.ts | 48 +-- ...op_accessing_security_scoped_resource.toml | 13 - .../fs/permissions/autogenerated/reference.md | 26 -- plugins/fs/permissions/schemas/schema.json | 12 - plugins/fs/src/commands.rs | 310 +++++++++++++----- plugins/fs/src/ios.rs | 4 +- plugins/fs/src/lib.rs | 1 - plugins/notification/api-iife.js | 2 +- plugins/window-state/api-iife.js | 2 +- 11 files changed, 253 insertions(+), 168 deletions(-) delete mode 100644 plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml diff --git a/plugins/fs/api-iife.js b/plugins/fs/api-iife.js index e3481bbc14..aabe9f3328 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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 R(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 L(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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 928df1cdf4..47e270034a 100644 --- a/plugins/fs/build.rs +++ b/plugins/fs/build.rs @@ -104,7 +104,6 @@ const COMMANDS: &[(&str, &[&str])] = &[ // TODO: Remove this in v3 ("unwatch", &[]), ("size", &[]), - ("stop_accessing_security_scoped_resource", &[]), ]; fn main() { diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index eb28fc521f..b28e3e8509 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 @@ -1348,38 +1361,6 @@ async function size(path: string | URL): Promise { }) } -/** - * 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). - * - * #### Platform-specific - * - * - **iOS:** Stops accessing the security-scoped resource. - * - **Other platforms:** No-op (does nothing). - * - * @example - * ```typescript - * import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; - * - * // After you're done with a file from a file picker - * await stopAccessingSecurityScopedResource('file:///path/to/file.txt'); - * ``` - * - * @since 2.4.4 - */ -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, @@ -1428,6 +1409,5 @@ export { exists, watch, watchImmediate, - size, - stopAccessingSecurityScopedResource + size } 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 deleted file mode 100644 index fae3f19cd4..0000000000 --- a/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml +++ /dev/null @@ -1,13 +0,0 @@ -# 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 2c3f7bf7a4..7f021a7f35 100644 --- a/plugins/fs/permissions/autogenerated/reference.md +++ b/plugins/fs/permissions/autogenerated/reference.md @@ -3461,32 +3461,6 @@ 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 9fca1a9482..e1c051f704 100644 --- a/plugins/fs/permissions/schemas/schema.json +++ b/plugins/fs/permissions/schemas/schema.json @@ -1872,18 +1872,6 @@ "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/commands.rs b/plugins/fs/src/commands.rs index 3c159d34c1..8c80325501 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,174 @@ 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, // Use Deref to get &PathBuf + } + } +} + +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()), + }; + // Try to stop accessing the security-scoped resource + // Ignore errors during drop as we can't propagate them + log::debug!("Stopping accessing security-scoped resource for path: {file_path}"); + let _ = self.app_handle.fs().stop_accessing_security_scoped_resource(file_path); + } + } + } +} + +/// 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()), + }; + // Try to stop accessing the security-scoped resource + // Ignore errors during drop as we can't propagate them + log::debug!("Stopping accessing security-scoped resource for path: {file_path}"); + let _ = self.app_handle.fs().stop_accessing_security_scoped_resource(file_path); + } + } +} + #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BaseOptions { @@ -84,7 +253,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 +262,15 @@ 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 +291,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 +319,7 @@ 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 +480,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 +517,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 +536,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() ) })?; @@ -599,8 +771,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), @@ -623,7 +795,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, @@ -637,10 +809,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)) @@ -795,7 +967,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) @@ -807,8 +979,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) } @@ -856,7 +1028,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, @@ -903,11 +1075,11 @@ 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) @@ -1010,32 +1182,6 @@ fn get_dir_size(path: &PathBuf) -> CommandResult { Ok(size) } -#[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), - SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), - }; - webview - .fs() - .stop_accessing_security_scoped_resource(file_path)?; - Ok(()) - } - #[cfg(not(target_os = "ios"))] - { - // No-op on non-iOS platforms - let _ = webview; - let _ = path; - Ok(()) - } -} #[cfg(desktop)] pub fn resolve_file( @@ -1045,7 +1191,7 @@ pub fn resolve_file( command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, -) -> CommandResult<(File, PathBuf)> { +) -> CommandResult> { resolve_file_in_fs( permission, webview, @@ -1063,8 +1209,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, @@ -1074,14 +1221,16 @@ 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(mobile)] @@ -1092,16 +1241,18 @@ 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, @@ -1121,7 +1272,8 @@ 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 #[cfg(target_os = "ios")] { @@ -1137,7 +1289,9 @@ pub fn resolve_path( // This is required for files outside the app's sandbox (e.g., from file picker) unsafe { let success = ns_url.startAccessingSecurityScopedResource(); - if !success { + if success { + log::debug!("Started accessing security-scoped resource for URL: {}", url.as_str()); + } else { log::warn!("Failed to start accessing security-scoped resource for URL: {}", url.as_str()); } } @@ -1149,7 +1303,7 @@ pub fn resolve_path( } 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 @@ -1178,23 +1332,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) @@ -1240,20 +1395,21 @@ 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(); + // FileHandle implements DerefMut to File, so we can use it directly + f(&mut *file_handle) } } -impl Resource for StdFileResource {} +impl Resource for StdFileResource {} /// Same as [std::io::Lines] but with bytes struct LinesBytes(T); diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs index 11eccf1b08..4211c9a31d 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -51,7 +51,9 @@ impl Fs { // The access will be automatically stopped when the app is backgrounded or terminated. unsafe { let success = ns_url.startAccessingSecurityScopedResource(); - if !success { + 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); } } diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index 9a87e28478..7eed800dc3 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -421,7 +421,6 @@ pub fn init() -> TauriPlugin> { commands::write_text_file, commands::exists, commands::size, - commands::stop_accessing_security_scoped_resource, #[cfg(feature = "watch")] watcher::watch, ]) diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js index d8f85c6cf3..68cf16b4c2 100644 --- a/plugins/notification/api-iife.js +++ b/plugins/notification/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i){"use strict";function t(i,t,n,e){if("function"==typeof t?i!==t||!e:!t.has(i))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?e:"a"===n?e.call(i):e?e.value:t.get(i)}function n(i,t,n,e,a){if("function"==typeof t||!t.has(i))throw new TypeError("Cannot write private member to an object whose class did not declare it");return t.set(i,n),n}var e,a,o,r;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(i){e.set(this,void 0),a.set(this,0),o.set(this,[]),r.set(this,void 0),n(this,e,i||(()=>{})),this.id=function(i,t=!1){return window.__TAURI_INTERNALS__.transformCallback(i,t)}((i=>{const s=i.index;if("end"in i)return void(s==t(this,a,"f")?this.cleanupCallback():n(this,r,s));const c=i.message;if(s==t(this,a,"f")){for(t(this,e,"f").call(this,c),n(this,a,t(this,a,"f")+1);t(this,a,"f")in t(this,o,"f");){const i=t(this,o,"f")[t(this,a,"f")];t(this,e,"f").call(this,i),delete t(this,o,"f")[t(this,a,"f")],n(this,a,t(this,a,"f")+1)}t(this,a,"f")===t(this,r,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(i){n(this,e,i)}get onmessage(){return t(this,e,"f")}[(e=new WeakMap,a=new WeakMap,o=new WeakMap,r=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}class l{constructor(i,t,n){this.plugin=i,this.event=t,this.channelId=n}async unregister(){return f(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function u(i,t,n){const e=new c(n);try{return await f(`plugin:${i}|register_listener`,{event:t,handler:e}),new l(i,t,e.id)}catch{return await f(`plugin:${i}|registerListener`,{event:t,handler:e}),new l(i,t,e.id)}}async function f(i,t={},n){return window.__TAURI_INTERNALS__.invoke(i,t,n)}var h,d,w;i.ScheduleEvery=void 0,(h=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",h.Month="month",h.TwoWeeks="twoWeeks",h.Week="week",h.Day="day",h.Hour="hour",h.Minute="minute",h.Second="second";return i.Importance=void 0,(d=i.Importance||(i.Importance={}))[d.None=0]="None",d[d.Min=1]="Min",d[d.Low=2]="Low",d[d.Default=3]="Default",d[d.High=4]="High",i.Visibility=void 0,(w=i.Visibility||(i.Visibility={}))[w.Secret=-1]="Secret",w[w.Private=0]="Private",w[w.Public=1]="Public",i.Schedule=class{static at(i,t=!1,n=!1){return{at:{date:i,repeating:t,allowWhileIdle:n},interval:void 0,every:void 0}}static interval(i,t=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:t},every:void 0}}static every(i,t,n=!1){return{at:void 0,interval:void 0,every:{interval:i,count:t,allowWhileIdle:n}}}},i.active=async function(){return await f("plugin:notification|get_active")},i.cancel=async function(i){await f("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await f("plugin:notification|cancel")},i.channels=async function(){return await f("plugin:notification|listChannels")},i.createChannel=async function(i){await f("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await f("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await u("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await u("notification","notification",i)},i.pending=async function(){return await f("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await f("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await f("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await f("plugin:notification|remove_active")},i.removeChannel=async function(i){await f("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({});Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i){"use strict";function n(i,n,t,e){if("function"==typeof n?i!==n||!e:!n.has(i))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===t?e:"a"===t?e.call(i):e?e.value:n.get(i)}function t(i,n,t,e,a){if("function"==typeof n||!n.has(i))throw new TypeError("Cannot write private member to an object whose class did not declare it");return n.set(i,t),t}var e,a,o,r;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(i){e.set(this,void 0),a.set(this,0),o.set(this,[]),r.set(this,void 0),t(this,e,i||(()=>{})),this.id=function(i,n=!1){return window.__TAURI_INTERNALS__.transformCallback(i,n)}((i=>{const s=i.index;if("end"in i)return void(s==n(this,a,"f")?this.cleanupCallback():t(this,r,s));const c=i.message;if(s==n(this,a,"f")){for(n(this,e,"f").call(this,c),t(this,a,n(this,a,"f")+1);n(this,a,"f")in n(this,o,"f");){const i=n(this,o,"f")[n(this,a,"f")];n(this,e,"f").call(this,i),delete n(this,o,"f")[n(this,a,"f")],t(this,a,n(this,a,"f")+1)}n(this,a,"f")===n(this,r,"f")&&this.cleanupCallback()}else n(this,o,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(i){t(this,e,i)}get onmessage(){return n(this,e,"f")}[(e=new WeakMap,a=new WeakMap,o=new WeakMap,r=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}class l{constructor(i,n,t){this.plugin=i,this.event=n,this.channelId=t}async unregister(){return f(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function u(i,n,t){const e=new c(t);return f(`plugin:${i}|registerListener`,{event:n,handler:e}).then((()=>new l(i,n,e.id)))}async function f(i,n={},t){return window.__TAURI_INTERNALS__.invoke(i,n,t)}var h,d,w;i.ScheduleEvery=void 0,(h=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",h.Month="month",h.TwoWeeks="twoWeeks",h.Week="week",h.Day="day",h.Hour="hour",h.Minute="minute",h.Second="second";return i.Importance=void 0,(d=i.Importance||(i.Importance={}))[d.None=0]="None",d[d.Min=1]="Min",d[d.Low=2]="Low",d[d.Default=3]="Default",d[d.High=4]="High",i.Visibility=void 0,(w=i.Visibility||(i.Visibility={}))[w.Secret=-1]="Secret",w[w.Private=0]="Private",w[w.Public=1]="Public",i.Schedule=class{static at(i,n=!1,t=!1){return{at:{date:i,repeating:n,allowWhileIdle:t},interval:void 0,every:void 0}}static interval(i,n=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:n},every:void 0}}static every(i,n,t=!1){return{at:void 0,interval:void 0,every:{interval:i,count:n,allowWhileIdle:t}}}},i.active=async function(){return await f("plugin:notification|get_active")},i.cancel=async function(i){await f("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await f("plugin:notification|cancel")},i.channels=async function(){return await f("plugin:notification|listChannels")},i.createChannel=async function(i){await f("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await f("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await u("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await u("notification","notification",i)},i.pending=async function(){return await f("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await f("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await f("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await f("plugin:notification|remove_active")},i.removeChannel=async function(i){await f("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({});Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})} diff --git a/plugins/window-state/api-iife.js b/plugins/window-state/api-iife.js index feb6f4a05f..b9586faa56 100644 --- a/plugins/window-state/api-iife.js +++ b/plugins/window-state/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_WINDOW_STATE__=function(e){"use strict";var t;"function"==typeof SuppressedError&&SuppressedError;const i="__TAURI_TO_IPC_KEY__";function n(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}async function a(e,t={},i){return window.__TAURI_INTERNALS__.invoke(e,t,i)}class l{get rid(){return function(e,t,i,n){if("function"==typeof t?e!==t||!n:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===i?n:"a"===i?n.call(e):n?n.value:t.get(e)}(this,t,"f")}constructor(e){t.set(this,void 0),function(e,t,i){if("function"==typeof t||!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");t.set(e,i)}(this,t,e)}async close(){return a("plugin:resources|close",{rid:this.rid})}}t=new WeakMap;class s{constructor(...e){this.type="Logical",1===e.length?"Logical"in e[0]?(this.width=e[0].Logical.width,this.height=e[0].Logical.height):(this.width=e[0].width,this.height=e[0].height):(this.width=e[0],this.height=e[1])}toPhysical(e){return new r(this.width*e,this.height*e)}[i](){return{width:this.width,height:this.height}}toJSON(){return this[i]()}}class r{constructor(...e){this.type="Physical",1===e.length?"Physical"in e[0]?(this.width=e[0].Physical.width,this.height=e[0].Physical.height):(this.width=e[0].width,this.height=e[0].height):(this.width=e[0],this.height=e[1])}toLogical(e){return new s(this.width/e,this.height/e)}[i](){return{width:this.width,height:this.height}}toJSON(){return this[i]()}}class o{constructor(e){this.size=e}toLogical(e){return this.size instanceof s?this.size:this.size.toLogical(e)}toPhysical(e){return this.size instanceof r?this.size:this.size.toPhysical(e)}[i](){return{[`${this.size.type}`]:{width:this.size.width,height:this.size.height}}}toJSON(){return this[i]()}}class u{constructor(...e){this.type="Logical",1===e.length?"Logical"in e[0]?(this.x=e[0].Logical.x,this.y=e[0].Logical.y):(this.x=e[0].x,this.y=e[0].y):(this.x=e[0],this.y=e[1])}toPhysical(e){return new c(this.x*e,this.y*e)}[i](){return{x:this.x,y:this.y}}toJSON(){return this[i]()}}class c{constructor(...e){this.type="Physical",1===e.length?"Physical"in e[0]?(this.x=e[0].Physical.x,this.y=e[0].Physical.y):(this.x=e[0].x,this.y=e[0].y):(this.x=e[0],this.y=e[1])}toLogical(e){return new u(this.x/e,this.y/e)}[i](){return{x:this.x,y:this.y}}toJSON(){return this[i]()}}class h{constructor(e){this.position=e}toLogical(e){return this.position instanceof u?this.position:this.position.toLogical(e)}toPhysical(e){return this.position instanceof c?this.position:this.position.toPhysical(e)}[i](){return{[`${this.position.type}`]:{x:this.position.x,y:this.position.y}}}toJSON(){return this[i]()}}var d,w,b;async function g(e,t){window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(e,t),await a("plugin:event|unlisten",{event:e,eventId:t})}async function y(e,t,i){var l;const s="string"==typeof(null==i?void 0:i.target)?{kind:"AnyLabel",label:i.target}:null!==(l=null==i?void 0:i.target)&&void 0!==l?l:{kind:"Any"};return a("plugin:event|listen",{event:e,target:s,handler:n(t)}).then((t=>async()=>g(e,t)))}!function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"}(d||(d={}));class p extends l{constructor(e){super(e)}static async new(e,t,i){return a("plugin:image|new",{rgba:_(e),width:t,height:i}).then((e=>new p(e)))}static async fromBytes(e){return a("plugin:image|from_bytes",{bytes:_(e)}).then((e=>new p(e)))}static async fromPath(e){return a("plugin:image|from_path",{path:e}).then((e=>new p(e)))}async rgba(){return a("plugin:image|rgba",{rid:this.rid}).then((e=>new Uint8Array(e)))}async size(){return a("plugin:image|size",{rid:this.rid})}}function _(e){return null==e?null:"string"==typeof e?e:e instanceof p?e.rid:e}!function(e){e[e.Critical=1]="Critical",e[e.Informational=2]="Informational"}(w||(w={}));class v{constructor(e){this._preventDefault=!1,this.event=e.event,this.id=e.id}preventDefault(){this._preventDefault=!0}isPreventDefault(){return this._preventDefault}}function f(){return new I(window.__TAURI_INTERNALS__.metadata.currentWindow.label,{skip:!0})}async function m(){return a("plugin:window|get_all_windows").then((e=>e.map((e=>new I(e,{skip:!0})))))}!function(e){e.None="none",e.Normal="normal",e.Indeterminate="indeterminate",e.Paused="paused",e.Error="error"}(b||(b={}));const E=["tauri://created","tauri://error"];class I{constructor(e,t={}){var i;this.label=e,this.listeners=Object.create(null),(null==t?void 0:t.skip)||a("plugin:window|create",{options:{...t,parent:"string"==typeof t.parent?t.parent:null===(i=t.parent)||void 0===i?void 0:i.label,label:e}}).then((async()=>this.emit("tauri://created"))).catch((async e=>this.emit("tauri://error",e)))}static async getByLabel(e){var t;return null!==(t=(await m()).find((t=>t.label===e)))&&void 0!==t?t:null}static getCurrent(){return f()}static async getAll(){return m()}static async getFocusedWindow(){for(const e of await m())if(await e.isFocused())return e;return null}async listen(e,t){return this._handleTauriEvent(e,t)?()=>{const i=this.listeners[e];i.splice(i.indexOf(t),1)}:y(e,t,{target:{kind:"Window",label:this.label}})}async once(e,t){return this._handleTauriEvent(e,t)?()=>{const i=this.listeners[e];i.splice(i.indexOf(t),1)}:async function(e,t,i){return y(e,(i=>{g(e,i.id),t(i)}),i)}(e,t,{target:{kind:"Window",label:this.label}})}async emit(e,t){if(!E.includes(e))return async function(e,t){await a("plugin:event|emit",{event:e,payload:t})}(e,t);for(const i of this.listeners[e]||[])i({event:e,id:-1,payload:t})}async emitTo(e,t,i){if(!E.includes(t))return async function(e,t,i){const n="string"==typeof e?{kind:"AnyLabel",label:e}:e;await a("plugin:event|emit_to",{target:n,event:t,payload:i})}(e,t,i);for(const e of this.listeners[t]||[])e({event:t,id:-1,payload:i})}_handleTauriEvent(e,t){return!!E.includes(e)&&(e in this.listeners?this.listeners[e].push(t):this.listeners[e]=[t],!0)}async scaleFactor(){return a("plugin:window|scale_factor",{label:this.label})}async innerPosition(){return a("plugin:window|inner_position",{label:this.label}).then((e=>new c(e)))}async outerPosition(){return a("plugin:window|outer_position",{label:this.label}).then((e=>new c(e)))}async innerSize(){return a("plugin:window|inner_size",{label:this.label}).then((e=>new r(e)))}async outerSize(){return a("plugin:window|outer_size",{label:this.label}).then((e=>new r(e)))}async isFullscreen(){return a("plugin:window|is_fullscreen",{label:this.label})}async isMinimized(){return a("plugin:window|is_minimized",{label:this.label})}async isMaximized(){return a("plugin:window|is_maximized",{label:this.label})}async isFocused(){return a("plugin:window|is_focused",{label:this.label})}async isDecorated(){return a("plugin:window|is_decorated",{label:this.label})}async isResizable(){return a("plugin:window|is_resizable",{label:this.label})}async isMaximizable(){return a("plugin:window|is_maximizable",{label:this.label})}async isMinimizable(){return a("plugin:window|is_minimizable",{label:this.label})}async isClosable(){return a("plugin:window|is_closable",{label:this.label})}async isVisible(){return a("plugin:window|is_visible",{label:this.label})}async title(){return a("plugin:window|title",{label:this.label})}async theme(){return a("plugin:window|theme",{label:this.label})}async isAlwaysOnTop(){return a("plugin:window|is_always_on_top",{label:this.label})}async center(){return a("plugin:window|center",{label:this.label})}async requestUserAttention(e){let t=null;return e&&(t=e===w.Critical?{type:"Critical"}:{type:"Informational"}),a("plugin:window|request_user_attention",{label:this.label,value:t})}async setResizable(e){return a("plugin:window|set_resizable",{label:this.label,value:e})}async setEnabled(e){return a("plugin:window|set_enabled",{label:this.label,value:e})}async isEnabled(){return a("plugin:window|is_enabled",{label:this.label})}async setMaximizable(e){return a("plugin:window|set_maximizable",{label:this.label,value:e})}async setMinimizable(e){return a("plugin:window|set_minimizable",{label:this.label,value:e})}async setClosable(e){return a("plugin:window|set_closable",{label:this.label,value:e})}async setTitle(e){return a("plugin:window|set_title",{label:this.label,value:e})}async maximize(){return a("plugin:window|maximize",{label:this.label})}async unmaximize(){return a("plugin:window|unmaximize",{label:this.label})}async toggleMaximize(){return a("plugin:window|toggle_maximize",{label:this.label})}async minimize(){return a("plugin:window|minimize",{label:this.label})}async unminimize(){return a("plugin:window|unminimize",{label:this.label})}async show(){return a("plugin:window|show",{label:this.label})}async hide(){return a("plugin:window|hide",{label:this.label})}async close(){return a("plugin:window|close",{label:this.label})}async destroy(){return a("plugin:window|destroy",{label:this.label})}async setDecorations(e){return a("plugin:window|set_decorations",{label:this.label,value:e})}async setShadow(e){return a("plugin:window|set_shadow",{label:this.label,value:e})}async setEffects(e){return a("plugin:window|set_effects",{label:this.label,value:e})}async clearEffects(){return a("plugin:window|set_effects",{label:this.label,value:null})}async setAlwaysOnTop(e){return a("plugin:window|set_always_on_top",{label:this.label,value:e})}async setAlwaysOnBottom(e){return a("plugin:window|set_always_on_bottom",{label:this.label,value:e})}async setContentProtected(e){return a("plugin:window|set_content_protected",{label:this.label,value:e})}async setSize(e){return a("plugin:window|set_size",{label:this.label,value:e instanceof o?e:new o(e)})}async setMinSize(e){return a("plugin:window|set_min_size",{label:this.label,value:e instanceof o?e:e?new o(e):null})}async setMaxSize(e){return a("plugin:window|set_max_size",{label:this.label,value:e instanceof o?e:e?new o(e):null})}async setSizeConstraints(e){function t(e){return e?{Logical:e}:null}return a("plugin:window|set_size_constraints",{label:this.label,value:{minWidth:t(null==e?void 0:e.minWidth),minHeight:t(null==e?void 0:e.minHeight),maxWidth:t(null==e?void 0:e.maxWidth),maxHeight:t(null==e?void 0:e.maxHeight)}})}async setPosition(e){return a("plugin:window|set_position",{label:this.label,value:e instanceof h?e:new h(e)})}async setFullscreen(e){return a("plugin:window|set_fullscreen",{label:this.label,value:e})}async setSimpleFullscreen(e){return a("plugin:window|set_simple_fullscreen",{label:this.label,value:e})}async setFocus(){return a("plugin:window|set_focus",{label:this.label})}async setFocusable(e){return a("plugin:window|set_focusable",{label:this.label,value:e})}async setIcon(e){return a("plugin:window|set_icon",{label:this.label,value:_(e)})}async setSkipTaskbar(e){return a("plugin:window|set_skip_taskbar",{label:this.label,value:e})}async setCursorGrab(e){return a("plugin:window|set_cursor_grab",{label:this.label,value:e})}async setCursorVisible(e){return a("plugin:window|set_cursor_visible",{label:this.label,value:e})}async setCursorIcon(e){return a("plugin:window|set_cursor_icon",{label:this.label,value:e})}async setBackgroundColor(e){return a("plugin:window|set_background_color",{color:e})}async setCursorPosition(e){return a("plugin:window|set_cursor_position",{label:this.label,value:e instanceof h?e:new h(e)})}async setIgnoreCursorEvents(e){return a("plugin:window|set_ignore_cursor_events",{label:this.label,value:e})}async startDragging(){return a("plugin:window|start_dragging",{label:this.label})}async startResizeDragging(e){return a("plugin:window|start_resize_dragging",{label:this.label,value:e})}async setBadgeCount(e){return a("plugin:window|set_badge_count",{label:this.label,value:e})}async setBadgeLabel(e){return a("plugin:window|set_badge_label",{label:this.label,value:e})}async setOverlayIcon(e){return a("plugin:window|set_overlay_icon",{label:this.label,value:e?_(e):void 0})}async setProgressBar(e){return a("plugin:window|set_progress_bar",{label:this.label,value:e})}async setVisibleOnAllWorkspaces(e){return a("plugin:window|set_visible_on_all_workspaces",{label:this.label,value:e})}async setTitleBarStyle(e){return a("plugin:window|set_title_bar_style",{label:this.label,value:e})}async setTheme(e){return a("plugin:window|set_theme",{label:this.label,value:e})}async onResized(e){return this.listen(d.WINDOW_RESIZED,(t=>{t.payload=new r(t.payload),e(t)}))}async onMoved(e){return this.listen(d.WINDOW_MOVED,(t=>{t.payload=new c(t.payload),e(t)}))}async onCloseRequested(e){return this.listen(d.WINDOW_CLOSE_REQUESTED,(async t=>{const i=new v(t);await e(i),i.isPreventDefault()||await this.destroy()}))}async onDragDropEvent(e){const t=await this.listen(d.DRAG_ENTER,(t=>{e({...t,payload:{type:"enter",paths:t.payload.paths,position:new c(t.payload.position)}})})),i=await this.listen(d.DRAG_OVER,(t=>{e({...t,payload:{type:"over",position:new c(t.payload.position)}})})),n=await this.listen(d.DRAG_DROP,(t=>{e({...t,payload:{type:"drop",paths:t.payload.paths,position:new c(t.payload.position)}})})),a=await this.listen(d.DRAG_LEAVE,(t=>{e({...t,payload:{type:"leave"}})}));return()=>{t(),n(),i(),a()}}async onFocusChanged(e){const t=await this.listen(d.WINDOW_FOCUS,(t=>{e({...t,payload:!0})})),i=await this.listen(d.WINDOW_BLUR,(t=>{e({...t,payload:!1})}));return()=>{t(),i()}}async onScaleChanged(e){return this.listen(d.WINDOW_SCALE_FACTOR_CHANGED,e)}async onThemeChanged(e){return this.listen(d.WINDOW_THEME_CHANGED,e)}}var D,S,O,A,W;async function z(e,t){await a("plugin:window-state|restore_state",{label:e,flags:t})}return function(e){e.Disabled="disabled",e.Throttle="throttle",e.Suspend="suspend"}(D||(D={})),function(e){e.Default="default",e.FluentOverlay="fluentOverlay"}(S||(S={})),function(e){e.AppearanceBased="appearanceBased",e.Light="light",e.Dark="dark",e.MediumLight="mediumLight",e.UltraDark="ultraDark",e.Titlebar="titlebar",e.Selection="selection",e.Menu="menu",e.Popover="popover",e.Sidebar="sidebar",e.HeaderView="headerView",e.Sheet="sheet",e.WindowBackground="windowBackground",e.HudWindow="hudWindow",e.FullScreenUI="fullScreenUI",e.Tooltip="tooltip",e.ContentBackground="contentBackground",e.UnderWindowBackground="underWindowBackground",e.UnderPageBackground="underPageBackground",e.Mica="mica",e.Blur="blur",e.Acrylic="acrylic",e.Tabbed="tabbed",e.TabbedDark="tabbedDark",e.TabbedLight="tabbedLight"}(O||(O={})),function(e){e.FollowsWindowActiveState="followsWindowActiveState",e.Active="active",e.Inactive="inactive"}(A||(A={})),e.StateFlags=void 0,(W=e.StateFlags||(e.StateFlags={}))[W.SIZE=1]="SIZE",W[W.POSITION=2]="POSITION",W[W.MAXIMIZED=4]="MAXIMIZED",W[W.VISIBLE=8]="VISIBLE",W[W.DECORATIONS=16]="DECORATIONS",W[W.FULLSCREEN=32]="FULLSCREEN",W[W.ALL=63]="ALL",e.filename=async function(){return await a("plugin:window-state|filename")},e.restoreState=z,e.restoreStateCurrent=async function(e){await z(f().label,e)},e.saveWindowState=async function(e){await a("plugin:window-state|save_window_state",{flags:e})},e}({});Object.defineProperty(window.__TAURI__,"windowState",{value:__TAURI_PLUGIN_WINDOW_STATE__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_WINDOW_STATE__=function(e){"use strict";var t;"function"==typeof SuppressedError&&SuppressedError;const i="__TAURI_TO_IPC_KEY__";function n(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}async function a(e,t={},i){return window.__TAURI_INTERNALS__.invoke(e,t,i)}class l{get rid(){return function(e,t,i,n){if("function"==typeof t?e!==t||!n:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===i?n:"a"===i?n.call(e):n?n.value:t.get(e)}(this,t,"f")}constructor(e){t.set(this,void 0),function(e,t,i){if("function"==typeof t||!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");t.set(e,i)}(this,t,e)}async close(){return a("plugin:resources|close",{rid:this.rid})}}t=new WeakMap;class s{constructor(...e){this.type="Logical",1===e.length?"Logical"in e[0]?(this.width=e[0].Logical.width,this.height=e[0].Logical.height):(this.width=e[0].width,this.height=e[0].height):(this.width=e[0],this.height=e[1])}toPhysical(e){return new r(this.width*e,this.height*e)}[i](){return{width:this.width,height:this.height}}toJSON(){return this[i]()}}class r{constructor(...e){this.type="Physical",1===e.length?"Physical"in e[0]?(this.width=e[0].Physical.width,this.height=e[0].Physical.height):(this.width=e[0].width,this.height=e[0].height):(this.width=e[0],this.height=e[1])}toLogical(e){return new s(this.width/e,this.height/e)}[i](){return{width:this.width,height:this.height}}toJSON(){return this[i]()}}class o{constructor(e){this.size=e}toLogical(e){return this.size instanceof s?this.size:this.size.toLogical(e)}toPhysical(e){return this.size instanceof r?this.size:this.size.toPhysical(e)}[i](){return{[`${this.size.type}`]:{width:this.size.width,height:this.size.height}}}toJSON(){return this[i]()}}class u{constructor(...e){this.type="Logical",1===e.length?"Logical"in e[0]?(this.x=e[0].Logical.x,this.y=e[0].Logical.y):(this.x=e[0].x,this.y=e[0].y):(this.x=e[0],this.y=e[1])}toPhysical(e){return new c(this.x*e,this.y*e)}[i](){return{x:this.x,y:this.y}}toJSON(){return this[i]()}}class c{constructor(...e){this.type="Physical",1===e.length?"Physical"in e[0]?(this.x=e[0].Physical.x,this.y=e[0].Physical.y):(this.x=e[0].x,this.y=e[0].y):(this.x=e[0],this.y=e[1])}toLogical(e){return new u(this.x/e,this.y/e)}[i](){return{x:this.x,y:this.y}}toJSON(){return this[i]()}}class h{constructor(e){this.position=e}toLogical(e){return this.position instanceof u?this.position:this.position.toLogical(e)}toPhysical(e){return this.position instanceof c?this.position:this.position.toPhysical(e)}[i](){return{[`${this.position.type}`]:{x:this.position.x,y:this.position.y}}}toJSON(){return this[i]()}}var d,w,b;async function g(e,t){window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(e,t),await a("plugin:event|unlisten",{event:e,eventId:t})}async function p(e,t,i){var l;const s="string"==typeof(null==i?void 0:i.target)?{kind:"AnyLabel",label:i.target}:null!==(l=null==i?void 0:i.target)&&void 0!==l?l:{kind:"Any"};return a("plugin:event|listen",{event:e,target:s,handler:n(t)}).then((t=>async()=>g(e,t)))}!function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"}(d||(d={}));class y extends l{constructor(e){super(e)}static async new(e,t,i){return a("plugin:image|new",{rgba:_(e),width:t,height:i}).then((e=>new y(e)))}static async fromBytes(e){return a("plugin:image|from_bytes",{bytes:_(e)}).then((e=>new y(e)))}static async fromPath(e){return a("plugin:image|from_path",{path:e}).then((e=>new y(e)))}async rgba(){return a("plugin:image|rgba",{rid:this.rid}).then((e=>new Uint8Array(e)))}async size(){return a("plugin:image|size",{rid:this.rid})}}function _(e){return null==e?null:"string"==typeof e?e:e instanceof y?e.rid:e}!function(e){e[e.Critical=1]="Critical",e[e.Informational=2]="Informational"}(w||(w={}));class v{constructor(e){this._preventDefault=!1,this.event=e.event,this.id=e.id}preventDefault(){this._preventDefault=!0}isPreventDefault(){return this._preventDefault}}function f(){return new I(window.__TAURI_INTERNALS__.metadata.currentWindow.label,{skip:!0})}async function m(){return a("plugin:window|get_all_windows").then((e=>e.map((e=>new I(e,{skip:!0})))))}!function(e){e.None="none",e.Normal="normal",e.Indeterminate="indeterminate",e.Paused="paused",e.Error="error"}(b||(b={}));const E=["tauri://created","tauri://error"];class I{constructor(e,t={}){var i;this.label=e,this.listeners=Object.create(null),(null==t?void 0:t.skip)||a("plugin:window|create",{options:{...t,parent:"string"==typeof t.parent?t.parent:null===(i=t.parent)||void 0===i?void 0:i.label,label:e}}).then((async()=>this.emit("tauri://created"))).catch((async e=>this.emit("tauri://error",e)))}static async getByLabel(e){var t;return null!==(t=(await m()).find((t=>t.label===e)))&&void 0!==t?t:null}static getCurrent(){return f()}static async getAll(){return m()}static async getFocusedWindow(){for(const e of await m())if(await e.isFocused())return e;return null}async listen(e,t){return this._handleTauriEvent(e,t)?()=>{const i=this.listeners[e];i.splice(i.indexOf(t),1)}:p(e,t,{target:{kind:"Window",label:this.label}})}async once(e,t){return this._handleTauriEvent(e,t)?()=>{const i=this.listeners[e];i.splice(i.indexOf(t),1)}:async function(e,t,i){return p(e,(i=>{g(e,i.id),t(i)}),i)}(e,t,{target:{kind:"Window",label:this.label}})}async emit(e,t){if(!E.includes(e))return async function(e,t){await a("plugin:event|emit",{event:e,payload:t})}(e,t);for(const i of this.listeners[e]||[])i({event:e,id:-1,payload:t})}async emitTo(e,t,i){if(!E.includes(t))return async function(e,t,i){const n="string"==typeof e?{kind:"AnyLabel",label:e}:e;await a("plugin:event|emit_to",{target:n,event:t,payload:i})}(e,t,i);for(const e of this.listeners[t]||[])e({event:t,id:-1,payload:i})}_handleTauriEvent(e,t){return!!E.includes(e)&&(e in this.listeners?this.listeners[e].push(t):this.listeners[e]=[t],!0)}async scaleFactor(){return a("plugin:window|scale_factor",{label:this.label})}async innerPosition(){return a("plugin:window|inner_position",{label:this.label}).then((e=>new c(e)))}async outerPosition(){return a("plugin:window|outer_position",{label:this.label}).then((e=>new c(e)))}async innerSize(){return a("plugin:window|inner_size",{label:this.label}).then((e=>new r(e)))}async outerSize(){return a("plugin:window|outer_size",{label:this.label}).then((e=>new r(e)))}async isFullscreen(){return a("plugin:window|is_fullscreen",{label:this.label})}async isMinimized(){return a("plugin:window|is_minimized",{label:this.label})}async isMaximized(){return a("plugin:window|is_maximized",{label:this.label})}async isFocused(){return a("plugin:window|is_focused",{label:this.label})}async isDecorated(){return a("plugin:window|is_decorated",{label:this.label})}async isResizable(){return a("plugin:window|is_resizable",{label:this.label})}async isMaximizable(){return a("plugin:window|is_maximizable",{label:this.label})}async isMinimizable(){return a("plugin:window|is_minimizable",{label:this.label})}async isClosable(){return a("plugin:window|is_closable",{label:this.label})}async isVisible(){return a("plugin:window|is_visible",{label:this.label})}async title(){return a("plugin:window|title",{label:this.label})}async theme(){return a("plugin:window|theme",{label:this.label})}async isAlwaysOnTop(){return a("plugin:window|is_always_on_top",{label:this.label})}async center(){return a("plugin:window|center",{label:this.label})}async requestUserAttention(e){let t=null;return e&&(t=e===w.Critical?{type:"Critical"}:{type:"Informational"}),a("plugin:window|request_user_attention",{label:this.label,value:t})}async setResizable(e){return a("plugin:window|set_resizable",{label:this.label,value:e})}async setEnabled(e){return a("plugin:window|set_enabled",{label:this.label,value:e})}async isEnabled(){return a("plugin:window|is_enabled",{label:this.label})}async setMaximizable(e){return a("plugin:window|set_maximizable",{label:this.label,value:e})}async setMinimizable(e){return a("plugin:window|set_minimizable",{label:this.label,value:e})}async setClosable(e){return a("plugin:window|set_closable",{label:this.label,value:e})}async setTitle(e){return a("plugin:window|set_title",{label:this.label,value:e})}async maximize(){return a("plugin:window|maximize",{label:this.label})}async unmaximize(){return a("plugin:window|unmaximize",{label:this.label})}async toggleMaximize(){return a("plugin:window|toggle_maximize",{label:this.label})}async minimize(){return a("plugin:window|minimize",{label:this.label})}async unminimize(){return a("plugin:window|unminimize",{label:this.label})}async show(){return a("plugin:window|show",{label:this.label})}async hide(){return a("plugin:window|hide",{label:this.label})}async close(){return a("plugin:window|close",{label:this.label})}async destroy(){return a("plugin:window|destroy",{label:this.label})}async setDecorations(e){return a("plugin:window|set_decorations",{label:this.label,value:e})}async setShadow(e){return a("plugin:window|set_shadow",{label:this.label,value:e})}async setEffects(e){return a("plugin:window|set_effects",{label:this.label,value:e})}async clearEffects(){return a("plugin:window|set_effects",{label:this.label,value:null})}async setAlwaysOnTop(e){return a("plugin:window|set_always_on_top",{label:this.label,value:e})}async setAlwaysOnBottom(e){return a("plugin:window|set_always_on_bottom",{label:this.label,value:e})}async setContentProtected(e){return a("plugin:window|set_content_protected",{label:this.label,value:e})}async setSize(e){return a("plugin:window|set_size",{label:this.label,value:e instanceof o?e:new o(e)})}async setMinSize(e){return a("plugin:window|set_min_size",{label:this.label,value:e instanceof o?e:e?new o(e):null})}async setMaxSize(e){return a("plugin:window|set_max_size",{label:this.label,value:e instanceof o?e:e?new o(e):null})}async setSizeConstraints(e){function t(e){return e?{Logical:e}:null}return a("plugin:window|set_size_constraints",{label:this.label,value:{minWidth:t(null==e?void 0:e.minWidth),minHeight:t(null==e?void 0:e.minHeight),maxWidth:t(null==e?void 0:e.maxWidth),maxHeight:t(null==e?void 0:e.maxHeight)}})}async setPosition(e){return a("plugin:window|set_position",{label:this.label,value:e instanceof h?e:new h(e)})}async setFullscreen(e){return a("plugin:window|set_fullscreen",{label:this.label,value:e})}async setSimpleFullscreen(e){return a("plugin:window|set_simple_fullscreen",{label:this.label,value:e})}async setFocus(){return a("plugin:window|set_focus",{label:this.label})}async setFocusable(e){return a("plugin:window|set_focusable",{label:this.label,value:e})}async setIcon(e){return a("plugin:window|set_icon",{label:this.label,value:_(e)})}async setSkipTaskbar(e){return a("plugin:window|set_skip_taskbar",{label:this.label,value:e})}async setCursorGrab(e){return a("plugin:window|set_cursor_grab",{label:this.label,value:e})}async setCursorVisible(e){return a("plugin:window|set_cursor_visible",{label:this.label,value:e})}async setCursorIcon(e){return a("plugin:window|set_cursor_icon",{label:this.label,value:e})}async setBackgroundColor(e){return a("plugin:window|set_background_color",{color:e})}async setCursorPosition(e){return a("plugin:window|set_cursor_position",{label:this.label,value:e instanceof h?e:new h(e)})}async setIgnoreCursorEvents(e){return a("plugin:window|set_ignore_cursor_events",{label:this.label,value:e})}async startDragging(){return a("plugin:window|start_dragging",{label:this.label})}async startResizeDragging(e){return a("plugin:window|start_resize_dragging",{label:this.label,value:e})}async setBadgeCount(e){return a("plugin:window|set_badge_count",{label:this.label,value:e})}async setBadgeLabel(e){return a("plugin:window|set_badge_label",{label:this.label,value:e})}async setOverlayIcon(e){return a("plugin:window|set_overlay_icon",{label:this.label,value:e?_(e):void 0})}async setProgressBar(e){return a("plugin:window|set_progress_bar",{label:this.label,value:e})}async setVisibleOnAllWorkspaces(e){return a("plugin:window|set_visible_on_all_workspaces",{label:this.label,value:e})}async setTitleBarStyle(e){return a("plugin:window|set_title_bar_style",{label:this.label,value:e})}async setTheme(e){return a("plugin:window|set_theme",{label:this.label,value:e})}async onResized(e){return this.listen(d.WINDOW_RESIZED,(t=>{t.payload=new r(t.payload),e(t)}))}async onMoved(e){return this.listen(d.WINDOW_MOVED,(t=>{t.payload=new c(t.payload),e(t)}))}async onCloseRequested(e){return this.listen(d.WINDOW_CLOSE_REQUESTED,(async t=>{const i=new v(t);await e(i),i.isPreventDefault()||await this.destroy()}))}async onDragDropEvent(e){const t=await this.listen(d.DRAG_ENTER,(t=>{e({...t,payload:{type:"enter",paths:t.payload.paths,position:new c(t.payload.position)}})})),i=await this.listen(d.DRAG_OVER,(t=>{e({...t,payload:{type:"over",position:new c(t.payload.position)}})})),n=await this.listen(d.DRAG_DROP,(t=>{e({...t,payload:{type:"drop",paths:t.payload.paths,position:new c(t.payload.position)}})})),a=await this.listen(d.DRAG_LEAVE,(t=>{e({...t,payload:{type:"leave"}})}));return()=>{t(),n(),i(),a()}}async onFocusChanged(e){const t=await this.listen(d.WINDOW_FOCUS,(t=>{e({...t,payload:!0})})),i=await this.listen(d.WINDOW_BLUR,(t=>{e({...t,payload:!1})}));return()=>{t(),i()}}async onScaleChanged(e){return this.listen(d.WINDOW_SCALE_FACTOR_CHANGED,e)}async onThemeChanged(e){return this.listen(d.WINDOW_THEME_CHANGED,e)}}var D,S,A,W;async function O(e,t){await a("plugin:window-state|restore_state",{label:e,flags:t})}return function(e){e.Disabled="disabled",e.Throttle="throttle",e.Suspend="suspend"}(D||(D={})),function(e){e.AppearanceBased="appearanceBased",e.Light="light",e.Dark="dark",e.MediumLight="mediumLight",e.UltraDark="ultraDark",e.Titlebar="titlebar",e.Selection="selection",e.Menu="menu",e.Popover="popover",e.Sidebar="sidebar",e.HeaderView="headerView",e.Sheet="sheet",e.WindowBackground="windowBackground",e.HudWindow="hudWindow",e.FullScreenUI="fullScreenUI",e.Tooltip="tooltip",e.ContentBackground="contentBackground",e.UnderWindowBackground="underWindowBackground",e.UnderPageBackground="underPageBackground",e.Mica="mica",e.Blur="blur",e.Acrylic="acrylic",e.Tabbed="tabbed",e.TabbedDark="tabbedDark",e.TabbedLight="tabbedLight"}(S||(S={})),function(e){e.FollowsWindowActiveState="followsWindowActiveState",e.Active="active",e.Inactive="inactive"}(A||(A={})),e.StateFlags=void 0,(W=e.StateFlags||(e.StateFlags={}))[W.SIZE=1]="SIZE",W[W.POSITION=2]="POSITION",W[W.MAXIMIZED=4]="MAXIMIZED",W[W.VISIBLE=8]="VISIBLE",W[W.DECORATIONS=16]="DECORATIONS",W[W.FULLSCREEN=32]="FULLSCREEN",W[W.ALL=63]="ALL",e.filename=async function(){return await a("plugin:window-state|filename")},e.restoreState=O,e.restoreStateCurrent=async function(e){await O(f().label,e)},e.saveWindowState=async function(e){await a("plugin:window-state|save_window_state",{flags:e})},e}({});Object.defineProperty(window.__TAURI__,"windowState",{value:__TAURI_PLUGIN_WINDOW_STATE__})} From 52cf3fcba10924103ce1a248d81db8bc90637096 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 10:48:48 -0300 Subject: [PATCH 07/11] add APIs for manual scope implementation --- plugins/fs/api-iife.js | 2 +- plugins/fs/build.rs | 2 + plugins/fs/guest-js/index.ts | 77 +++++- ...rt_accessing_security_scoped_resource.toml | 13 + ...op_accessing_security_scoped_resource.toml | 13 + .../fs/permissions/autogenerated/reference.md | 52 ++++ plugins/fs/permissions/schemas/schema.json | 24 ++ plugins/fs/src/commands.rs | 257 +++++++++++++++--- plugins/fs/src/ios.rs | 15 +- plugins/fs/src/lib.rs | 55 ++++ 10 files changed, 470 insertions(+), 40 deletions(-) create mode 100644 plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml create mode 100644 plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml diff --git a/plugins/fs/api-iife.js b/plugins/fs/api-iife.js index aabe9f3328..dd11832cb8 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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 L(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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__})} +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,w;function d(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,(w=t.SeekMode||(t.SeekMode={}))[w.Start=0]="Start",w[w.Current=1]="Current",w[w.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 R(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 d(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).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(){null===this.rid&&(this.rid=await l("plugin:fs|read_text_file_lines",{path:n,options:e}));const t=await l("plugin:fs|read_text_file_lines_next",{rid:this.rid}),i=t instanceof ArrayBuffer?new Uint8Array(t):Uint8Array.from(t),o=1===i[i.byteLength-1];if(o)return this.rid=null,{value:null,done:o};return{value:(new TextDecoder).decode(i.slice(0,i.byteLength-1)),done:o}},[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 d(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 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__})} 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 b28e3e8509..09910f4a18 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -1361,6 +1361,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.4.4 + */ +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.4.4 + */ +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, @@ -1409,5 +1482,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/commands.rs b/plugins/fs/src/commands.rs index 8c80325501..d43bcf6e0e 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -168,10 +168,30 @@ impl Drop for FileHandle { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; - // Try to stop accessing the security-scoped resource - // Ignore errors during drop as we can't propagate them - log::debug!("Stopping accessing security-scoped resource for path: {file_path}"); - let _ = self.app_handle.fs().stop_accessing_security_scoped_resource(file_path); + + // 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)"); + } + } + } } } } @@ -187,18 +207,13 @@ pub struct PathHandle { } impl PathHandle { - fn new( - path: PathBuf, - path_: SafeFilePath, - app_handle: tauri::AppHandle, - ) -> Self { + fn new(path: PathBuf, path_: SafeFilePath, app_handle: tauri::AppHandle) -> Self { Self { path, path_, app_handle, } } - } impl Deref for PathHandle { @@ -231,10 +246,30 @@ impl Drop for PathHandle { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; - // Try to stop accessing the security-scoped resource - // Ignore errors during drop as we can't propagate them - log::debug!("Stopping accessing security-scoped resource for path: {file_path}"); - let _ = self.app_handle.fs().stop_accessing_security_scoped_resource(file_path); + + // 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)"); + } + } + } } } } @@ -269,8 +304,15 @@ pub fn create( ) })?; 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)); + 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) } @@ -319,7 +361,9 @@ pub fn open( }, )?; - let rid = webview.resources_table().add(StdFileResource::new(file_handle)); + let rid = webview + .resources_table() + .add(StdFileResource::new(file_handle)); Ok(rid) } @@ -1075,7 +1119,8 @@ async fn write_file_inner( _ => return Err(anyhow::anyhow!("unexpected invoke body").into()), }; - file_handle.write_all(&data) + file_handle + .write_all(&data) .map_err(|e| { format!( "failed to write bytes to file at path: {} with error: {e}", @@ -1165,6 +1210,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; @@ -1182,7 +1351,6 @@ fn get_dir_size(path: &PathBuf) -> CommandResult { Ok(size) } - #[cfg(desktop)] pub fn resolve_file( permission: &str, @@ -1228,9 +1396,14 @@ fn resolve_file_in_fs( resolved_path_handle.display() ) })?; - + let app_handle = webview.app_handle().clone(); - Ok(FileHandle::new(file, PathKind::Handle(resolved_path_handle), path_, app_handle)) + Ok(FileHandle::new( + file, + PathKind::Handle(resolved_path_handle), + path_, + app_handle, + )) } #[cfg(mobile)] @@ -1252,7 +1425,12 @@ pub fn resolve_file( .fs() .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)) + Ok(FileHandle::new( + file, + PathKind::Path(resolved_path), + path_, + app_handle, + )) } SafeFilePath::Path(path) => resolve_file_in_fs( permission, @@ -1275,28 +1453,37 @@ pub fn resolve_path( ) -> 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 url_string = url.as_str(); - let url_nsstring = NSString::from_str(url_string); - 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: {}", url.as_str()); - } else { - log::warn!("Failed to start accessing security-scoped resource for URL: {}", url.as_str()); + 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!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url.as_str()); + log::debug!("Security-scoped resource already active for URL: {} (started via start_accessing_security_scoped_resource), skipping", url.as_str()); } } } diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs index 4211c9a31d..c4a4335427 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -52,9 +52,15 @@ impl Fs { unsafe { let success = ns_url.startAccessingSecurityScopedResource(); if success { - log::debug!("Started accessing security-scoped resource for URL: {}", url_string); + 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); + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url_string + ); } } } else { @@ -120,7 +126,10 @@ impl Fs { ns_url.stopAccessingSecurityScopedResource(); } } else { - return Err(crate::Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "failed to create NSURL from URL"))); + 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 7eed800dc3..39f4853276 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -10,6 +10,8 @@ )] use std::io::Read; +#[cfg(target_os = "ios")] +use std::sync::Mutex; use serde::Deserialize; use tauri::{ @@ -373,6 +375,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; @@ -421,6 +473,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, ]) @@ -447,6 +501,7 @@ pub fn init() -> TauriPlugin> { app.manage(Fs(app.clone())); app.manage(scope); + app.manage(SecurityScopedResources::new()); Ok(()) }) .on_event(|app, event| { From 741fbec1086627d431b29146810f49ed76298749 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 10:54:20 -0300 Subject: [PATCH 08/11] update @since --- plugins/fs/guest-js/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index 09910f4a18..8ad8646c1a 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -1385,7 +1385,7 @@ async function size(path: string | URL): Promise { * // ... use the resource ... * ``` * - * @since 2.4.4 + * @since 2.5.0 */ async function startAccessingSecurityScopedResource( path: string | URL @@ -1420,7 +1420,7 @@ async function startAccessingSecurityScopedResource( * await stopAccessingSecurityScopedResource(filePath); * ``` * - * @since 2.4.4 + * @since 2.5.0 */ async function stopAccessingSecurityScopedResource( path: string | URL From 33a68108786319e8f8bd0eb1643d1a37929a97ca Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Mon, 5 Jan 2026 14:20:15 -0300 Subject: [PATCH 09/11] clippy --- plugins/fs/src/commands.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index d43bcf6e0e..3f0088aa5e 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -93,7 +93,7 @@ impl PathKind { pub fn as_path_buf(&self) -> &PathBuf { match self { PathKind::Path(p) => p, - PathKind::Handle(h) => &**h, // Use Deref to get &PathBuf + PathKind::Handle(h) => h, } } } @@ -1591,8 +1591,7 @@ impl StdFileResource { fn with_lock Ret>(&self, mut f: F) -> Ret { let mut file_handle = self.0.lock().unwrap(); - // FileHandle implements DerefMut to File, so we can use it directly - f(&mut *file_handle) + f(&mut file_handle) } } From 198831769513dc9cf4e8d47832cfcf44f3fb9558 Mon Sep 17 00:00:00 2001 From: Fabian-Lars <30730186+FabianLars@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:58:57 +0100 Subject: [PATCH 10/11] minor bump --- .changes/security-scoped-ios.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changes/security-scoped-ios.md b/.changes/security-scoped-ios.md index 9ba7810837..0b7343491b 100644 --- a/.changes/security-scoped-ios.md +++ b/.changes/security-scoped-ios.md @@ -1,6 +1,6 @@ --- -"fs": patch -"fs-js": patch +"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. From 526e2e13358cfc70975210597fcfeee827ba5b2b Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Wed, 4 Mar 2026 09:19:19 -0300 Subject: [PATCH 11/11] chore: add todo --- plugins/fs/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index 39f4853276..05fe675513 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -4,6 +4,9 @@ //! 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"