diff --git a/Cargo.lock b/Cargo.lock index 3daa9a83ec..6770c91ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4733,6 +4733,7 @@ dependencies = [ "tauri-plugin-extensions", "tauri-plugin-fs", "tauri-plugin-fs-sync", + "tauri-plugin-fs2", "tauri-plugin-hooks", "tauri-plugin-http", "tauri-plugin-icon", @@ -18301,6 +18302,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-fs2" +version = "0.1.0" +dependencies = [ + "serde", + "specta", + "specta-typescript", + "tauri", + "tauri-plugin", + "tauri-plugin-path2", + "tauri-specta", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "tauri-plugin-hooks" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 79bd65f225..a7fc0f7aed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,6 +143,7 @@ tauri-plugin-deeplink2 = { path = "plugins/deeplink2" } tauri-plugin-detect = { path = "plugins/detect" } tauri-plugin-extensions = { path = "plugins/extensions" } tauri-plugin-fs-sync = { path = "plugins/fs-sync" } +tauri-plugin-fs2 = { path = "plugins/fs2" } tauri-plugin-hooks = { path = "plugins/hooks" } tauri-plugin-icon = { path = "plugins/icon" } tauri-plugin-importer = { path = "plugins/importer" } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index af6ae84961..bfbd545200 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -46,6 +46,7 @@ "@hypr/plugin-detect": "workspace:*", "@hypr/plugin-extensions": "workspace:*", "@hypr/plugin-fs-sync": "workspace:*", + "@hypr/plugin-fs2": "workspace:*", "@hypr/plugin-hooks": "workspace:*", "@hypr/plugin-icon": "workspace:*", "@hypr/plugin-importer": "workspace:*", @@ -90,7 +91,6 @@ "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", - "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-http": "^2.5.4", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-os": "^2.3.2", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ed01d4dde9..455f5c0bad 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -36,8 +36,8 @@ tauri-plugin-deeplink2 = { workspace = true } tauri-plugin-detect = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-extensions = { workspace = true } -tauri-plugin-fs = { workspace = true } tauri-plugin-fs-sync = { workspace = true } +tauri-plugin-fs2 = { workspace = true } tauri-plugin-hooks = { workspace = true } tauri-plugin-http = { workspace = true } tauri-plugin-icon = { workspace = true } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index f894789167..6b98241617 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -49,168 +49,6 @@ } ] }, - { - "identifier": "fs:allow-mkdir", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-read-dir", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-exists", - "allow": [ - { - "path": "/Applications/*" - }, - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-read-file", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-read-text-file", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-write-text-file", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - }, - { - "path": "$DOWNLOAD/**/*" - } - ] - }, - { - "identifier": "fs:allow-write-file", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - }, - { - "path": "$DOWNLOAD/**/*" - } - ] - }, - { - "identifier": "fs:allow-remove", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, - { - "identifier": "fs:allow-rename", - "allow": [ - { - "path": "$DATA/hyprnote" - }, - { - "path": "$DATA/hyprnote/**/*" - }, - { - "path": "$APPDATA" - }, - { - "path": "$APPDATA/**/*" - } - ] - }, "apple-calendar:default", "apple-contact:default", "audio-priority:default", @@ -258,6 +96,7 @@ }, "misc:default", "fs-sync:default", + "fs2:default", "os:default", "detect:default", "permissions:default", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0f7d6d929b..663ba81c1d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -104,8 +104,8 @@ pub async fn main() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deeplink2::init()) .plugin(tauri_plugin_fs_sync::init()) + .plugin(tauri_plugin_fs2::init()) .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_path2::init()) .plugin(tauri_plugin_pdf::init()) .plugin(tauri_plugin_process::init()) diff --git a/apps/desktop/src/store/tinybase/persister/chat/load.ts b/apps/desktop/src/store/tinybase/persister/chat/load.ts index 420e281ecd..5cf373cc36 100644 --- a/apps/desktop/src/store/tinybase/persister/chat/load.ts +++ b/apps/desktop/src/store/tinybase/persister/chat/load.ts @@ -1,6 +1,6 @@ import { sep } from "@tauri-apps/api/path"; -import { readTextFile } from "@tauri-apps/plugin-fs"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync"; import { @@ -96,15 +96,26 @@ export async function loadSingleChatGroup( ): Promise> { const filePath = [dataDir, "chats", groupId, CHAT_MESSAGES_FILE].join(sep()); + const result = await fs2Commands.readTextFile(filePath); + if (result.status === "error") { + if (isFileNotFoundError(result.error)) { + return ok(createEmptyLoadedChatData()); + } + console.error( + `[${LABEL}] Failed to load chat group ${groupId}:`, + result.error, + ); + return err(result.error); + } + try { - const content = await readTextFile(filePath); - const json = JSON.parse(content) as ChatJson; + const json = JSON.parse(result.data) as ChatJson; return ok(chatJsonToData(json)); } catch (error) { - if (isFileNotFoundError(error)) { - return ok(createEmptyLoadedChatData()); - } - console.error(`[${LABEL}] Failed to load chat group ${groupId}:`, error); + console.error( + `[${LABEL}] Failed to parse chat JSON for ${groupId}:`, + error, + ); return err(String(error)); } } diff --git a/apps/desktop/src/store/tinybase/persister/factories/collector.ts b/apps/desktop/src/store/tinybase/persister/factories/collector.ts index 4433604da2..752f24d708 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/collector.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/collector.ts @@ -1,4 +1,3 @@ -import { remove } from "@tauri-apps/plugin-fs"; import { createCustomPersister } from "tinybase/persisters/with-schemas"; import type { PersistedChanges, @@ -11,6 +10,7 @@ import type { OptionalSchemas, } from "tinybase/with-schemas"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; import { commands as fsSyncCommands, type ParsedDocument, @@ -235,12 +235,11 @@ async function deleteFiles(paths: string[], label: string): Promise { if (paths.length === 0) return; for (const path of paths) { - try { - await remove(path); - } catch (error) { - const errorStr = String(error); + const result = await fs2Commands.remove(path); + if (result.status === "error") { + const errorStr = result.error; if (!errorStr.includes("No such file") && !errorStr.includes("ENOENT")) { - console.error(`[${label}] Failed to delete file ${path}:`, error); + console.error(`[${label}] Failed to delete file ${path}:`, errorStr); } } } diff --git a/apps/desktop/src/store/tinybase/persister/factories/json-file.test.ts b/apps/desktop/src/store/tinybase/persister/factories/json-file.test.ts index b86c810c98..308fa66d8f 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/json-file.test.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/json-file.test.ts @@ -7,9 +7,9 @@ const path2Mocks = vi.hoisted(() => ({ base: vi.fn().mockResolvedValue("/mock/data/dir/hyprnote"), })); -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn().mockResolvedValue(undefined), +const fs2Mocks = vi.hoisted(() => ({ readTextFile: vi.fn(), + remove: vi.fn(), })); const fsSyncMocks = vi.hoisted(() => ({ @@ -23,7 +23,7 @@ const notifyMocks = vi.hoisted(() => ({ })); vi.mock("@hypr/plugin-path2", () => ({ commands: path2Mocks })); -vi.mock("@tauri-apps/plugin-fs", () => fsMocks); +vi.mock("@hypr/plugin-fs2", () => ({ commands: fs2Mocks })); vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); vi.mock("@hypr/plugin-notify", () => ({ events: notifyMocks })); @@ -67,7 +67,10 @@ describe("createJsonFilePersister", () => { recurrence_series_id: "", }, }; - fsMocks.readTextFile.mockResolvedValue(JSON.stringify(mockData)); + fs2Mocks.readTextFile.mockResolvedValue({ + status: "ok", + data: JSON.stringify(mockData), + }); const persister = createJsonFilePersister(store, { tableName: "events", @@ -76,16 +79,17 @@ describe("createJsonFilePersister", () => { }); await persister.load(); - expect(fsMocks.readTextFile).toHaveBeenCalledWith( + expect(fs2Mocks.readTextFile).toHaveBeenCalledWith( `${MOCK_DATA_DIR}/test.json`, ); expect(store.getTable("events")).toEqual(mockData); }); test("handles file not found gracefully", async () => { - fsMocks.readTextFile.mockRejectedValue( - new Error("No such file or directory"), - ); + fs2Mocks.readTextFile.mockResolvedValue({ + status: "error", + error: "No such file or directory", + }); const persister = createJsonFilePersister(store, { tableName: "events", diff --git a/apps/desktop/src/store/tinybase/persister/factories/json-file.ts b/apps/desktop/src/store/tinybase/persister/factories/json-file.ts index eb36b1fbbe..e56ecc8bcd 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/json-file.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/json-file.ts @@ -1,5 +1,4 @@ import { sep } from "@tauri-apps/api/path"; -import { readTextFile } from "@tauri-apps/plugin-fs"; import { createCustomPersister } from "tinybase/persisters/with-schemas"; import type { PersistedChanges, @@ -7,6 +6,7 @@ import type { } from "tinybase/persisters/with-schemas"; import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; import { commands as fsSyncCommands, type JsonValue, @@ -159,14 +159,21 @@ async function loadTableData( filename: string, label: string, ): Promise> | undefined> { + const base = await path2Commands.base(); + const path = [base, filename].join(sep()); + const result = await fs2Commands.readTextFile(path); + + if (result.status === "error") { + if (!isFileNotFoundError(result.error)) { + console.error(`[${label}] load error:`, result.error); + } + return undefined; + } + try { - const base = await path2Commands.base(); - const content = await readTextFile([base, filename].join(sep())); - return JSON.parse(content); + return JSON.parse(result.data); } catch (error) { - if (!isFileNotFoundError(error)) { - console.error(`[${label}] load error:`, error); - } + console.error(`[${label}] JSON parse error:`, error); return undefined; } } diff --git a/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.test.ts b/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.test.ts index c23b80c523..9d4564b744 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.test.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.test.ts @@ -22,18 +22,14 @@ const fsSyncMocks = vi.hoisted(() => ({ cleanupOrphan: vi.fn().mockResolvedValue({ status: "ok", data: 0 }), })); -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - readDir: vi.fn(), +const fs2Mocks = vi.hoisted(() => ({ readTextFile: vi.fn(), - writeTextFile: vi.fn().mockResolvedValue(undefined), - exists: vi.fn(), - remove: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), })); vi.mock("@hypr/plugin-path2", () => ({ commands: path2Mocks })); vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); -vi.mock("@tauri-apps/plugin-fs", () => fsMocks); +vi.mock("@hypr/plugin-fs2", () => ({ commands: fs2Mocks })); const testConfig = { tableName: "humans", diff --git a/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.ts b/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.ts index acadda2785..22e4036248 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/markdown-dir.ts @@ -1,6 +1,6 @@ -import { readTextFile } from "@tauri-apps/plugin-fs"; import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; import { commands as fsSyncCommands, type JsonValue, @@ -109,24 +109,9 @@ async function loadSingleEntity< const dataDir = await getDataDir(); const filePath = buildEntityFilePath(dataDir, dirName, entityId); - try { - const content = await readTextFile(filePath); - const parseResult = await fsSyncCommands.deserialize(content); - - if (parseResult.status === "error") { - return undefined; - } - - const entity = fromFrontmatter( - parseResult.data.frontmatter as Record, - parseResult.data.content.trim(), - ); - - return toPersistedChanges({ - [tableName]: { [entityId]: entity as Record }, - }); - } catch (error) { - if (isFileNotFoundError(error)) { + const readResult = await fs2Commands.readTextFile(filePath); + if (readResult.status === "error") { + if (isFileNotFoundError(readResult.error)) { const loaded = { [tableName]: {} } as LoadedData; const result = deletionMarker.markForEntity(loaded, entityId); @@ -136,6 +121,21 @@ async function loadSingleEntity< } return undefined; } + + const parseResult = await fsSyncCommands.deserialize(readResult.data); + + if (parseResult.status === "error") { + return undefined; + } + + const entity = fromFrontmatter( + parseResult.data.frontmatter as Record, + parseResult.data.content.trim(), + ); + + return toPersistedChanges({ + [tableName]: { [entityId]: entity as Record }, + }); } export function createMarkdownDirPersister< diff --git a/apps/desktop/src/store/tinybase/persister/factories/multi-table-dir.test.ts b/apps/desktop/src/store/tinybase/persister/factories/multi-table-dir.test.ts index f4433c8dc2..4d2ca2402b 100644 --- a/apps/desktop/src/store/tinybase/persister/factories/multi-table-dir.test.ts +++ b/apps/desktop/src/store/tinybase/persister/factories/multi-table-dir.test.ts @@ -16,18 +16,14 @@ const fsSyncMocks = vi.hoisted(() => ({ cleanupOrphan: vi.fn().mockResolvedValue({ status: "ok", data: 0 }), })); -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - readDir: vi.fn(), +const fs2Mocks = vi.hoisted(() => ({ readTextFile: vi.fn(), - writeTextFile: vi.fn().mockResolvedValue(undefined), - exists: vi.fn(), - remove: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), })); vi.mock("@hypr/plugin-path2", () => ({ commands: path2Mocks })); vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); -vi.mock("@tauri-apps/plugin-fs", () => fsMocks); +vi.mock("@hypr/plugin-fs2", () => ({ commands: fs2Mocks })); describe("createMultiTableDirPersister", () => { let store: ReturnType; diff --git a/apps/desktop/src/store/tinybase/persister/human/persister.test.ts b/apps/desktop/src/store/tinybase/persister/human/persister.test.ts index 5e0e362463..7e558d8815 100644 --- a/apps/desktop/src/store/tinybase/persister/human/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/human/persister.test.ts @@ -19,18 +19,14 @@ const fsSyncMocks = vi.hoisted(() => ({ cleanupOrphan: vi.fn().mockResolvedValue({ status: "ok", data: 0 }), })); -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - readDir: vi.fn(), +const fs2Mocks = vi.hoisted(() => ({ readTextFile: vi.fn(), - writeTextFile: vi.fn().mockResolvedValue(undefined), - exists: vi.fn(), - remove: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), })); vi.mock("@hypr/plugin-path2", () => ({ commands: path2Mocks })); vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); -vi.mock("@tauri-apps/plugin-fs", () => fsMocks); +vi.mock("@hypr/plugin-fs2", () => ({ commands: fs2Mocks })); describe("createHumanPersister", () => { let store: ReturnType; diff --git a/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts b/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts index 855f16f5f5..c6a97993f9 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts @@ -15,18 +15,14 @@ const fsSyncMocks = vi.hoisted(() => ({ cleanupOrphan: vi.fn().mockResolvedValue({ status: "ok", data: 0 }), })); -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - readDir: vi.fn(), +const fs2Mocks = vi.hoisted(() => ({ readTextFile: vi.fn(), - writeTextFile: vi.fn().mockResolvedValue(undefined), - exists: vi.fn(), - remove: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), })); vi.mock("@hypr/plugin-path2", () => ({ commands: path2Mocks })); vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); -vi.mock("@tauri-apps/plugin-fs", () => fsMocks); +vi.mock("@hypr/plugin-fs2", () => ({ commands: fs2Mocks })); describe("createOrganizationPersister", () => { let store: ReturnType; diff --git a/apps/desktop/src/store/tinybase/store/importer.test.ts b/apps/desktop/src/store/tinybase/store/importer.test.ts index 247fbadc30..f82701f322 100644 --- a/apps/desktop/src/store/tinybase/store/importer.test.ts +++ b/apps/desktop/src/store/tinybase/store/importer.test.ts @@ -1,16 +1,28 @@ -import { readTextFile, remove } from "@tauri-apps/plugin-fs"; import { createMergeableStore } from "tinybase/with-schemas"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; +import { commands as path2Commands } from "@hypr/plugin-path2"; import { SCHEMA } from "@hypr/store"; import { importFromJson } from "./importer"; import type { Store } from "./main"; -vi.mock("@tauri-apps/plugin-fs", () => ({ - BaseDirectory: { Data: 0 }, - readTextFile: vi.fn(), - remove: vi.fn(), +vi.mock("@tauri-apps/api/path", () => ({ + sep: vi.fn(() => "/"), +})); + +vi.mock("@hypr/plugin-path2", () => ({ + commands: { + base: vi.fn().mockResolvedValue("/test/data"), + }, +})); + +vi.mock("@hypr/plugin-fs2", () => ({ + commands: { + readTextFile: vi.fn(), + remove: vi.fn(), + }, })); function createTestStore(): Store { @@ -24,14 +36,16 @@ describe("importFromJson", () => { let onPersistComplete: ReturnType; beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); + vi.mocked(path2Commands.base).mockResolvedValue("/test/data"); store = createTestStore(); onPersistComplete = vi.fn().mockResolvedValue(undefined); }); test("successfully imports data", async () => { - vi.mocked(readTextFile).mockResolvedValue( - JSON.stringify([ + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: JSON.stringify([ { folders: { "folder-1": { @@ -43,8 +57,11 @@ describe("importFromJson", () => { }, {}, ]), - ); - vi.mocked(remove).mockResolvedValue(undefined); + }); + vi.mocked(fs2Commands.remove).mockResolvedValue({ + status: "ok", + data: null, + }); const result = await importFromJson(store, onPersistComplete); @@ -53,15 +70,18 @@ describe("importFromJson", () => { rowsImported: 1, valuesImported: 0, }); - expect(readTextFile).toHaveBeenCalledWith("hyprnote/import.json", { - baseDir: 0, - }); + expect(fs2Commands.readTextFile).toHaveBeenCalledWith( + "/test/data/import.json", + ); expect(onPersistComplete).toHaveBeenCalledTimes(1); - expect(remove).toHaveBeenCalledWith("hyprnote/import.json", { baseDir: 0 }); + expect(fs2Commands.remove).toHaveBeenCalledWith("/test/data/import.json"); }); test("returns error for invalid JSON format - not array", async () => { - vi.mocked(readTextFile).mockResolvedValue("{}"); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: "{}", + }); const result = await importFromJson(store, onPersistComplete); @@ -70,11 +90,14 @@ describe("importFromJson", () => { "expected [tables, values] array", ); expect(onPersistComplete).not.toHaveBeenCalled(); - expect(remove).not.toHaveBeenCalled(); + expect(fs2Commands.remove).not.toHaveBeenCalled(); }); test("returns error for invalid JSON format - wrong array length", async () => { - vi.mocked(readTextFile).mockResolvedValue("[1, 2, 3]"); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: "[1, 2, 3]", + }); const result = await importFromJson(store, onPersistComplete); @@ -85,7 +108,10 @@ describe("importFromJson", () => { }); test("returns error when tables is not object or null", async () => { - vi.mocked(readTextFile).mockResolvedValue('["invalid", {}]'); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: '["invalid", {}]', + }); const result = await importFromJson(store, onPersistComplete); @@ -96,7 +122,10 @@ describe("importFromJson", () => { }); test("returns error when values is not object or null", async () => { - vi.mocked(readTextFile).mockResolvedValue('[{}, "invalid"]'); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: '[{}, "invalid"]', + }); const result = await importFromJson(store, onPersistComplete); @@ -107,8 +136,14 @@ describe("importFromJson", () => { }); test("handles null tables and values", async () => { - vi.mocked(readTextFile).mockResolvedValue("[null, null]"); - vi.mocked(remove).mockResolvedValue(undefined); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: "[null, null]", + }); + vi.mocked(fs2Commands.remove).mockResolvedValue({ + status: "ok", + data: null, + }); const result = await importFromJson(store, onPersistComplete); @@ -122,10 +157,14 @@ describe("importFromJson", () => { test("merges data into existing store", async () => { store.setValues({ current_llm_provider: "existing" }); - vi.mocked(readTextFile).mockResolvedValue( - JSON.stringify([{}, { current_stt_provider: "new" }]), - ); - vi.mocked(remove).mockResolvedValue(undefined); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: JSON.stringify([{}, { current_stt_provider: "new" }]), + }); + vi.mocked(fs2Commands.remove).mockResolvedValue({ + status: "ok", + data: null, + }); const result = await importFromJson(store, onPersistComplete); @@ -135,7 +174,10 @@ describe("importFromJson", () => { }); test("handles file read error", async () => { - vi.mocked(readTextFile).mockRejectedValue(new Error("File not found")); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "error", + error: "File not found", + }); const result = await importFromJson(store, onPersistComplete); @@ -145,8 +187,14 @@ describe("importFromJson", () => { }); test("remove is called only after onPersistComplete resolves", async () => { - vi.mocked(readTextFile).mockResolvedValue("[{}, {}]"); - vi.mocked(remove).mockResolvedValue(undefined); + vi.mocked(fs2Commands.readTextFile).mockResolvedValue({ + status: "ok", + data: "[{}, {}]", + }); + vi.mocked(fs2Commands.remove).mockResolvedValue({ + status: "ok", + data: null, + }); let persistCompleted = false; const deferredPersist = vi.fn().mockImplementation(async () => { @@ -154,13 +202,14 @@ describe("importFromJson", () => { persistCompleted = true; }); - vi.mocked(remove).mockImplementation(async () => { + vi.mocked(fs2Commands.remove).mockImplementation(async () => { expect(persistCompleted).toBe(true); + return { status: "ok", data: null }; }); await importFromJson(store, deferredPersist); expect(deferredPersist).toHaveBeenCalledTimes(1); - expect(remove).toHaveBeenCalledTimes(1); + expect(fs2Commands.remove).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/desktop/src/store/tinybase/store/importer.ts b/apps/desktop/src/store/tinybase/store/importer.ts index 30fcf83d1d..cd7d185f99 100644 --- a/apps/desktop/src/store/tinybase/store/importer.ts +++ b/apps/desktop/src/store/tinybase/store/importer.ts @@ -1,13 +1,14 @@ -import { BaseDirectory, readTextFile, remove } from "@tauri-apps/plugin-fs"; +import { sep } from "@tauri-apps/api/path"; import { createMergeableStore } from "tinybase/with-schemas"; +import { commands as fs2Commands } from "@hypr/plugin-fs2"; +import { commands as path2Commands } from "@hypr/plugin-path2"; import { SCHEMA } from "@hypr/store"; import { isValidTiptapContent, md2json } from "@hypr/tiptap/shared"; import type { Store } from "./main"; -const IMPORT_PATH = "hyprnote/import.json"; -const BASE_DIR = BaseDirectory.Data; +const IMPORT_FILENAME = "import.json"; export type ImportResult = | { status: "success"; rowsImported: number; valuesImported: number } @@ -128,12 +129,26 @@ export const importFromJson = async ( onPersistComplete: () => Promise, ): Promise => { try { - const content = await readTextFile(IMPORT_PATH, { baseDir: BASE_DIR }); - const parsed = parseImportContent(content); + const base = await path2Commands.base(); + const importPath = [base, IMPORT_FILENAME].join(sep()); + + const readResult = await fs2Commands.readTextFile(importPath); + if (readResult.status === "error") { + throw new Error(readResult.error); + } + + const parsed = parseImportContent(readResult.data); const { rowsImported, valuesImported } = mergeImportData(store, parsed); await onPersistComplete(); - await remove(IMPORT_PATH, { baseDir: BASE_DIR }); + + const removeResult = await fs2Commands.remove(importPath); + if (removeResult.status === "error") { + console.warn( + "[Importer] Failed to remove import file:", + removeResult.error, + ); + } console.log( `[Importer] Successfully imported ${rowsImported} rows and ${valuesImported} values`, diff --git a/plugins/fs2/.gitignore b/plugins/fs2/.gitignore new file mode 100644 index 0000000000..50d8e32e89 --- /dev/null +++ b/plugins/fs2/.gitignore @@ -0,0 +1,17 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist-js +dist diff --git a/plugins/fs2/Cargo.toml b/plugins/fs2/Cargo.toml new file mode 100644 index 0000000000..744396a838 --- /dev/null +++ b/plugins/fs2/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tauri-plugin-fs2" +version = "0.1.0" +authors = ["You"] +edition = "2021" +exclude = ["/js", "/node_modules"] +links = "tauri-plugin-fs2" +description = "" + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +specta-typescript = { workspace = true } +tokio = { workspace = true, features = ["macros"] } + +[dependencies] +tauri-plugin-path2 = { path = "../path2" } + +tauri = { workspace = true, features = ["test"] } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } + +serde = { workspace = true } +specta = { workspace = true } + +thiserror = { workspace = true } diff --git a/plugins/fs2/build.rs b/plugins/fs2/build.rs new file mode 100644 index 0000000000..c939b90725 --- /dev/null +++ b/plugins/fs2/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["read_text_file", "remove"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/plugins/fs2/js/bindings.gen.ts b/plugins/fs2/js/bindings.gen.ts new file mode 100644 index 0000000000..84c4ef50cc --- /dev/null +++ b/plugins/fs2/js/bindings.gen.ts @@ -0,0 +1,97 @@ +// @ts-nocheck + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + + +export const commands = { +async readTextFile(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs2|read_text_file", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async remove(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs2|remove", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} + +/** user-defined events **/ + + + +/** user-defined constants **/ + + + +/** user-defined types **/ + + + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +} diff --git a/plugins/fs2/js/index.ts b/plugins/fs2/js/index.ts new file mode 100644 index 0000000000..a96e122f03 --- /dev/null +++ b/plugins/fs2/js/index.ts @@ -0,0 +1 @@ +export * from "./bindings.gen"; diff --git a/plugins/fs2/package.json b/plugins/fs2/package.json new file mode 100644 index 0000000000..cac2e6012d --- /dev/null +++ b/plugins/fs2/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hypr/plugin-fs2", + "private": true, + "main": "./js/index.ts", + "scripts": { + "codegen": "cargo test -p tauri-plugin-fs2" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.1" + } +} diff --git a/plugins/fs2/permissions/autogenerated/commands/read_text_file.toml b/plugins/fs2/permissions/autogenerated/commands/read_text_file.toml new file mode 100644 index 0000000000..7a25115db9 --- /dev/null +++ b/plugins/fs2/permissions/autogenerated/commands/read_text_file.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-text-file" +description = "Enables the read_text_file command without any pre-configured scope." +commands.allow = ["read_text_file"] + +[[permission]] +identifier = "deny-read-text-file" +description = "Denies the read_text_file command without any pre-configured scope." +commands.deny = ["read_text_file"] diff --git a/plugins/fs2/permissions/autogenerated/commands/remove.toml b/plugins/fs2/permissions/autogenerated/commands/remove.toml new file mode 100644 index 0000000000..9c9791ebcb --- /dev/null +++ b/plugins/fs2/permissions/autogenerated/commands/remove.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove" +description = "Enables the remove command without any pre-configured scope." +commands.allow = ["remove"] + +[[permission]] +identifier = "deny-remove" +description = "Denies the remove command without any pre-configured scope." +commands.deny = ["remove"] diff --git a/plugins/fs2/permissions/autogenerated/reference.md b/plugins/fs2/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..a780651e3a --- /dev/null +++ b/plugins/fs2/permissions/autogenerated/reference.md @@ -0,0 +1,96 @@ +## Default Permission + +Default permissions for the fs2 plugin + +#### This default permission set includes the following: + +- `allow-read-text-file` +- `allow-remove` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`fs2:allow-ping` + + + +Enables the ping command without any pre-configured scope. + +
+ +`fs2:deny-ping` + + + +Denies the ping command without any pre-configured scope. + +
+ +`fs2:allow-read-text-file` + + + +Enables the read_text_file command without any pre-configured scope. + +
+ +`fs2:deny-read-text-file` + + + +Denies the read_text_file command without any pre-configured scope. + +
+ +`fs2:allow-remove` + + + +Enables the remove command without any pre-configured scope. + +
+ +`fs2:deny-remove` + + + +Denies the remove command without any pre-configured scope. + +
diff --git a/plugins/fs2/permissions/default.toml b/plugins/fs2/permissions/default.toml new file mode 100644 index 0000000000..7acacae56f --- /dev/null +++ b/plugins/fs2/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the fs2 plugin" +permissions = ["allow-read-text-file", "allow-remove"] diff --git a/plugins/fs2/permissions/schemas/schema.json b/plugins/fs2/permissions/schemas/schema.json new file mode 100644 index 0000000000..23e8223a53 --- /dev/null +++ b/plugins/fs2/permissions/schemas/schema.json @@ -0,0 +1,342 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the ping command without any pre-configured scope.", + "type": "string", + "const": "allow-ping", + "markdownDescription": "Enables the ping command without any pre-configured scope." + }, + { + "description": "Denies the ping command without any pre-configured scope.", + "type": "string", + "const": "deny-ping", + "markdownDescription": "Denies the ping command without any pre-configured scope." + }, + { + "description": "Enables the read_text_file command without any pre-configured scope.", + "type": "string", + "const": "allow-read-text-file", + "markdownDescription": "Enables the read_text_file command without any pre-configured scope." + }, + { + "description": "Denies the read_text_file command without any pre-configured scope.", + "type": "string", + "const": "deny-read-text-file", + "markdownDescription": "Denies the read_text_file command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Default permissions for the fs2 plugin\n#### This default permission set includes:\n\n- `allow-read-text-file`\n- `allow-remove`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the fs2 plugin\n#### This default permission set includes:\n\n- `allow-read-text-file`\n- `allow-remove`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/fs2/src/commands.rs b/plugins/fs2/src/commands.rs new file mode 100644 index 0000000000..884acb258f --- /dev/null +++ b/plugins/fs2/src/commands.rs @@ -0,0 +1,23 @@ +use std::path::PathBuf; + +use crate::Fs2PluginExt; + +#[tauri::command] +#[specta::specta] +pub(crate) async fn read_text_file( + app: tauri::AppHandle, + path: String, +) -> Result { + let path = PathBuf::from(path); + app.fs2().read_text_file(&path).map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn remove( + app: tauri::AppHandle, + path: String, +) -> Result<(), String> { + let path = PathBuf::from(path); + app.fs2().remove(&path).map_err(|e| e.to_string()) +} diff --git a/plugins/fs2/src/error.rs b/plugins/fs2/src/error.rs new file mode 100644 index 0000000000..546936d975 --- /dev/null +++ b/plugins/fs2/src/error.rs @@ -0,0 +1,22 @@ +use serde::{Serialize, ser::Serializer}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("path forbidden: {0}")] + PathForbidden(std::path::PathBuf), + #[error("path error: {0}")] + Path(String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/fs2/src/ext.rs b/plugins/fs2/src/ext.rs new file mode 100644 index 0000000000..dba94dac51 --- /dev/null +++ b/plugins/fs2/src/ext.rs @@ -0,0 +1,95 @@ +use std::path::{Path, PathBuf}; + +use tauri_plugin_path2::Path2PluginExt; + +pub struct Fs2<'a, R: tauri::Runtime, M: tauri::Manager> { + manager: &'a M, + _runtime: std::marker::PhantomData R>, +} + +impl<'a, R: tauri::Runtime, M: tauri::Manager> Fs2<'a, R, M> { + fn base(&self) -> Result { + self.manager + .path2() + .base() + .map_err(|e| crate::Error::Path(e.to_string())) + } + + fn validate_path(&self, path: &Path) -> Result { + let base = self.base()?; + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + + let canonical_base = base.canonicalize().unwrap_or_else(|_| base.clone()); + + let canonical_path = + if resolved.exists() { + resolved.canonicalize()? + } else { + let parent = resolved.parent().ok_or_else(|| { + crate::Error::Path("invalid path: no parent directory".to_string()) + })?; + + if parent.exists() { + let canonical_parent = parent.canonicalize()?; + canonical_parent.join(resolved.file_name().ok_or_else(|| { + crate::Error::Path("invalid path: no file name".to_string()) + })?) + } else { + resolved.clone() + } + }; + + if canonical_path.starts_with(&canonical_base) { + Ok(resolved) + } else { + Err(crate::Error::PathForbidden(resolved)) + } + } + + pub fn read_text_file(&self, path: &Path) -> Result { + let validated_path = self.validate_path(path)?; + let content = std::fs::read_to_string(&validated_path)?; + Ok(content) + } + + pub fn remove(&self, path: &Path) -> Result<(), crate::Error> { + let validated_path = self.validate_path(path)?; + + if !validated_path.exists() { + return Ok(()); + } + + let metadata = std::fs::symlink_metadata(&validated_path)?; + + if metadata.is_dir() { + std::fs::remove_dir_all(&validated_path)?; + } else { + std::fs::remove_file(&validated_path)?; + } + + Ok(()) + } +} + +pub trait Fs2PluginExt { + fn fs2(&self) -> Fs2<'_, R, Self> + where + Self: tauri::Manager + Sized; +} + +impl> Fs2PluginExt for T { + fn fs2(&self) -> Fs2<'_, R, Self> + where + Self: Sized, + { + Fs2 { + manager: self, + _runtime: std::marker::PhantomData, + } + } +} diff --git a/plugins/fs2/src/lib.rs b/plugins/fs2/src/lib.rs new file mode 100644 index 0000000000..e66944d78f --- /dev/null +++ b/plugins/fs2/src/lib.rs @@ -0,0 +1,48 @@ +mod commands; +mod error; +mod ext; + +pub use error::{Error, Result}; +pub use ext::*; + +const PLUGIN_NAME: &str = "fs2"; + +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .plugin_name(PLUGIN_NAME) + .commands(tauri_specta::collect_commands![ + commands::read_text_file::, + commands::remove::, + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Result) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + let specta_builder = make_specta_builder(); + + tauri::plugin::Builder::new(PLUGIN_NAME) + .invoke_handler(specta_builder.invoke_handler()) + .build() +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn export_types() { + const OUTPUT_FILE: &str = "./js/bindings.gen.ts"; + + make_specta_builder::() + .export( + specta_typescript::Typescript::default() + .formatter(specta_typescript::formatter::prettier) + .bigint(specta_typescript::BigIntExportBehavior::Number), + OUTPUT_FILE, + ) + .unwrap(); + + let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); + std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); + } +} diff --git a/plugins/fs2/tsconfig.json b/plugins/fs2/tsconfig.json new file mode 100644 index 0000000000..13b985325d --- /dev/null +++ b/plugins/fs2/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./js/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60e6f2f7bc..7042302e09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: '@hypr/plugin-fs-sync': specifier: workspace:* version: link:../../plugins/fs-sync + '@hypr/plugin-fs2': + specifier: workspace:* + version: link:../../plugins/fs2 '@hypr/plugin-hooks': specifier: workspace:* version: link:../../plugins/hooks @@ -416,9 +419,6 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.4.2 version: 2.4.2 - '@tauri-apps/plugin-fs': - specifier: ^2.4.4 - version: 2.4.4 '@tauri-apps/plugin-http': specifier: ^2.5.4 version: 2.5.4 @@ -1544,6 +1544,12 @@ importers: specifier: ^2.9.1 version: 2.9.1 + plugins/fs2: + dependencies: + '@tauri-apps/api': + specifier: ^2.9.1 + version: 2.9.1 + plugins/hooks: dependencies: '@tauri-apps/api': @@ -7495,9 +7501,6 @@ packages: '@tauri-apps/plugin-dialog@2.4.2': resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} - '@tauri-apps/plugin-fs@2.4.4': - resolution: {integrity: sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ==} - '@tauri-apps/plugin-http@2.5.4': resolution: {integrity: sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==} @@ -23290,10 +23293,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 - '@tauri-apps/plugin-fs@2.4.4': - dependencies: - '@tauri-apps/api': 2.9.1 - '@tauri-apps/plugin-http@2.5.4': dependencies: '@tauri-apps/api': 2.9.1