From cb4f92e238bab5c7a95798534614a46c038e4257 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Sun, 11 Oct 2026 19:38:43 -0700 Subject: [PATCH] Remember folder chooser location without restoring authority --- CHANGELOG.md | 1 + README.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/PARITY_MATRIX.md | 2 +- electron/folder-dialog-hint.cjs | 31 ++++++++++++++++++++++++ electron/main.cjs | 7 +++--- native/Sources/NativeBridge.swift | 19 +++++++++++++-- native/Tests/SecuritySmoke.swift | 7 ++++++ package.json | 2 +- src/App.tsx | 5 +++- src/lib/folderSessionHint.ts | 26 ++++++++++++++++++++ tests/folder-session-hint.test.ts | 40 +++++++++++++++++++++++++++++++ 12 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 electron/folder-dialog-hint.cjs create mode 100644 src/lib/folderSessionHint.ts create mode 100644 tests/folder-session-hint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edcaad..788b729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Replacement-workflow reliability and file navigation. - Added a built-in export appearance preference that can match the editor or force Light/Dark for TypeOpen HTML, PDF, PNG and print rendering while keeping custom editor CSS/templates out of the export pipeline. - Added an opt-in Page Setup rule that starts direct top-level H1 headings after the first on new printed pages. The rule is print-only for PDF/Print and browser-print of styled HTML; PNG and style-free HTML remain unchanged. - Split custom editor CSS into independent Light and Dark slots. Existing single custom themes migrate to both slots, System appearance switches between them live, and custom editor CSS remains excluded from export rendering. +- Open Folder now remembers the last successfully selected folder only as the starting location for the next native folder chooser. The stored hint is never listed, opened, revalidated or promoted into filesystem authority until the user confirms a folder normally. ## 0.1.6 — 2026-10-10 diff --git a/README.md b/README.md index 3634886..fc0fb5b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This project is an independent clean-room implementation. It does not contain Ty - Inline and block TeX math rendered with KaTeX. - Mermaid, sequence and legacy flowchart previews inside fenced code blocks. - Folder file tree, article list and document outline; the native runtime can create/rename/trash files and folders, refresh the tree, sort by name, modification time or creation time, optionally show hidden files, and reveal selected assets in Finder without promoting images into editable-document authority. +- Open Folder remembers the last successfully chosen folder as a chooser starting-location hint only; it does not reopen the folder or restore folder authority across launches. - Multi-document tabs with per-document dirty state, Save All, safe close prompts and next/previous tab navigation. - Native recent documents, reopen-closed history, Open Quickly, Reveal in File Tree and Open File Location. - Files, Articles and Open Quickly support optional relative exclusion globs for hiding generated or noisy entries without changing folder authority or Search in Folder. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 81d5bd6..4725205 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ The renderer is read from the application's `Resources/renderer` directory throu Filesystem authority is held in the native bridge. Paths selected through native open/save dialogs are recorded as document or folder grants; bridge handlers revalidate those grants instead of trusting renderer-supplied state. Changing the active folder immediately revokes authority inherited only from the previous folder. Local image previews are returned as `data:` URLs only after containment checks, and `file:` image loading is not allowed by the renderer CSP. -Clean-tab session persistence does not persist those runtime grants. The renderer may send ordered `{id, filePath, dirty}` intent, but the host records only clean saved paths that are already exact document grants, together with file and parent realpath/device/inode/type identity. Reading `session-v1.json` alone does not grant or read any stored target. **Restore Previous Session** is an explicit action; only then does the host revalidate the stored file and parent identities, skip missing/replaced/symlinked entries, create a fresh exact document grant, and perform the existing bound read. Persisted session entries never flow through Finder/system-open handling, and folder authority is not restored. +Clean-tab session persistence does not persist those runtime grants. The renderer may send ordered `{id, filePath, dirty}` intent, but the host records only clean saved paths that are already exact document grants, together with file and parent realpath/device/inode/type identity. Reading `session-v1.json` alone does not grant or read any stored target. **Restore Previous Session** is an explicit action; only then does the host revalidate the stored file and parent identities, skip missing/replaced/symlinked entries, create a fresh exact document grant, and perform the existing bound read. Persisted session entries never flow through Finder/system-open handling, and folder authority is not restored. Separately, the renderer may remember the last folder the user successfully selected and send it back only as the starting location for the next native **Open Folder** chooser; hosts do not stat, list, resolve or grant that hint before the user confirms a folder in the chooser. ## Document model diff --git a/docs/PARITY_MATRIX.md b/docs/PARITY_MATRIX.md index a785b91..9d14edc 100644 --- a/docs/PARITY_MATRIX.md +++ b/docs/PARITY_MATRIX.md @@ -73,7 +73,7 @@ These are intentionally tracked even when they are not part of the current imple | Priority | Reference capability still worth closing | TypeOpen state / intended boundary | | --- | --- | --- | | Next | Configurable outer-whitespace behavior | Explicit hard-break semantics are preserved and smart punctuation now delegates to the host's explicit native substitution controls. MDXEditor still trims outer document whitespace during root import/export even with `trim={false}`; preserving it would require an explicit conservative editor-state contract rather than an invisible TypeOpen-owned envelope | -| Later | Folder-session hints and stronger recovery/version workflows | Clean saved tabs now restore explicitly after native identity revalidation. Folder grants remain deliberately non-persistent; any future folder hint must still require a normal user-authorized Open Folder flow | +| Later | Folder-session hints and stronger recovery/version workflows | Open Folder now remembers the last successfully selected folder only as the next native chooser's starting-location hint; the hint is not read/listed/resolved or treated as authority, and folder grants remain deliberately non-persistent. Clean saved tabs restore explicitly after native identity revalidation; stronger recovery/version workflows remain | | Later | Multi-window / Open in New Window | Multi-tab model exists; the native app still centers on one main editing window | | Later | Advanced file library: pinned/recent locations and grouping | Native management, name/modified/creation-date sorting, a host-enforced hidden-file toggle and renderer-only relative exclusion globs exist. Persisted folder locations remain deferred because they must not imply restored filesystem authority | | Later | Advanced image management: configurable asset roots/copy rules, copy/move/rename/delete image assets, syntax switching, zoom/open actions | Safe local image insert/preview and adjacent `assets/` storage exist. Folder-tree image assets are kept out of the editable-document grant path; Electron preview/export reads bind validated image identity to the opened descriptor rather than reopening a pathname. Configurable write roots/copy rules remain deferred pending stronger destination-directory semantics; cloud/PicGo/PicList integration is intentionally deferred | diff --git a/electron/folder-dialog-hint.cjs b/electron/folder-dialog-hint.cjs new file mode 100644 index 0000000..46e7169 --- /dev/null +++ b/electron/folder-dialog-hint.cjs @@ -0,0 +1,31 @@ +const path = require('node:path') + +const MAX_FOLDER_DIALOG_HINT_LENGTH = 4096 + +function normalizeFolderDialogHint(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_FOLDER_DIALOG_HINT_LENGTH || value.includes('\0')) return null + if (!path.isAbsolute(value)) return null + return value +} + +function folderDialogHintFromPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null + const keys = Object.keys(payload) + if (keys.length !== 1 || keys[0] !== 'hintPath') return null + return normalizeFolderDialogHint(payload.hintPath) +} + +function folderDialogOptions(payload) { + const hintPath = folderDialogHintFromPayload(payload) + return { + properties: ['openDirectory'], + ...(hintPath ? { defaultPath: hintPath } : {}) + } +} + +module.exports = { + MAX_FOLDER_DIALOG_HINT_LENGTH, + folderDialogHintFromPayload, + folderDialogOptions, + normalizeFolderDialogHint +} diff --git a/electron/main.cjs b/electron/main.cjs index f73af86..d2bf606 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -26,6 +26,7 @@ const { } = require('./document-types.cjs') const { readAndGrantDocument } = require('./document-reader.cjs') const { readLocalImageFile } = require('./local-image-io.cjs') +const { folderDialogOptions } = require('./folder-dialog-hint.cjs') const execFileAsync = promisify(execFile) const DEV_SERVER = process.env.VITE_DEV_SERVER_URL @@ -333,8 +334,8 @@ async function chooseOpenFile() { } } -async function chooseOpenFolder() { - const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }) +async function chooseOpenFolder(payload) { + const result = await dialog.showOpenDialog(mainWindow, folderDialogOptions(payload)) if (result.canceled || result.filePaths.length === 0) return null const folderPath = result.filePaths[0] pathGrants.setFolder(folderPath) @@ -1262,7 +1263,7 @@ async function createWindow() { } ipcMain.handle('typeopen:open-file', async (event) => { assertTrustedRenderer(event); return chooseOpenFile() }) -ipcMain.handle('typeopen:open-folder', async (event) => { assertTrustedRenderer(event); return chooseOpenFolder() }) +ipcMain.handle('typeopen:open-folder', async (event, payload) => { assertTrustedRenderer(event); return chooseOpenFolder(payload) }) ipcMain.handle('typeopen:choose-theme-css', async (event) => { assertTrustedRenderer(event); return chooseThemeCss() }) ipcMain.handle('typeopen:read-file', async (event, filePath) => { assertTrustedRenderer(event) diff --git a/native/Sources/NativeBridge.swift b/native/Sources/NativeBridge.swift index fb0eca9..77ddbf9 100644 --- a/native/Sources/NativeBridge.swift +++ b/native/Sources/NativeBridge.swift @@ -23,6 +23,18 @@ private let maxRecoveryBytes = 16 * 1024 * 1024 private let maxRecoveryDocuments = 20 private let maxSessionBytes = 512 * 1024 private let maxSessionDocuments = 20 +private let maxFolderDialogHintLength = 4096 + +func normalizedFolderDialogHint(_ payload: Any?) -> String? { + guard let options = payload as? [String: Any], + options.count == 1, + let hintPath = options["hintPath"] as? String, + !hintPath.isEmpty, + hintPath.count <= maxFolderDialogHintLength, + !hintPath.contains("\0"), + (hintPath as NSString).isAbsolutePath else { return nil } + return hintPath +} func isEditableDocumentPath(_ filePath: String) -> Bool { let ext = URL(fileURLWithPath: filePath).pathExtension.lowercased() @@ -778,7 +790,7 @@ final class NativeBridge: NSObject, WKScriptMessageHandler, WKNavigationDelegate private func invokeSync(channel: String, payload: Any?) throws -> Any? { switch channel { case "typeopen:open-file": return try chooseOpenFile() - case "typeopen:open-folder": return try chooseOpenFolder() + case "typeopen:open-folder": return try chooseOpenFolder(payload: payload) case "typeopen:choose-theme-css": return try chooseThemeCSS() case "typeopen:read-file": return try readGrantedDocument(requireString(payload, "Document path is required.")) case "typeopen:list-folder": @@ -891,12 +903,15 @@ final class NativeBridge: NSObject, WKScriptMessageHandler, WKNavigationDelegate return ["filePath": path, "imported": pandocImportExtensions.contains(url.pathExtension.lowercased())] } - private func chooseOpenFolder() throws -> Any? { + private func chooseOpenFolder(payload: Any?) throws -> Any? { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = false panel.allowsMultipleSelection = false + if let hintPath = normalizedFolderDialogHint(payload) { + panel.directoryURL = URL(fileURLWithPath: hintPath, isDirectory: true) + } guard panel.runModal() == .OK, let url = panel.url else { return nil } let folderPath = try grants.setFolder(url.path) return ["folderPath": folderPath, "tree": try listDirectory(folderPath)] diff --git a/native/Tests/SecuritySmoke.swift b/native/Tests/SecuritySmoke.swift index c29d73e..53e2e3d 100644 --- a/native/Tests/SecuritySmoke.swift +++ b/native/Tests/SecuritySmoke.swift @@ -27,6 +27,13 @@ struct NativeSecuritySmoke { requireThemeRejected(#"@\69mport "https://example.invalid/theme.css";"#, "escaped @import") requireThemeRejected(#".x { content: "\41"; }"#, "generic CSS escape") + require(normalizedFolderDialogHint(["hintPath": "/tmp/typeopen-hint"]) == "/tmp/typeopen-hint", "absolute folder dialog hint rejected") + require(normalizedFolderDialogHint(["hintPath": "relative/folder"]) == nil, "relative folder dialog hint accepted") + require(normalizedFolderDialogHint(["hintPath": "/tmp/bad\0hint"]) == nil, "NUL folder dialog hint accepted") + require(normalizedFolderDialogHint(["hintPath": "/" + String(repeating: "a", count: 4097)]) == nil, "oversized folder dialog hint accepted") + require(normalizedFolderDialogHint(["hintPath": "/tmp/typeopen-hint", "extra": true]) == nil, "folder dialog hint with unknown key accepted") + require(normalizedFolderDialogHint(["hintPath"]) == nil, "array folder dialog hint accepted") + let fm = FileManager.default let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("typeopen-native-security-\(UUID().uuidString)", isDirectory: true) diff --git a/package.json b/package.json index 51e0301..ca591a1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev:electron": "concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"", "build": "vite build", "check:clean-room": "node scripts/check-clean-room.mjs", - "check:electron": "node --check electron/main.cjs && node --check electron/preload.cjs && node --check electron/textbundle.cjs && node --check electron/file-ops.cjs && node --check electron/theme-css.cjs && node --check electron/path-grants.cjs && node --check electron/document-types.cjs && node --check electron/document-reader.cjs && node --check electron/local-image-io.cjs", + "check:electron": "node --check electron/main.cjs && node --check electron/preload.cjs && node --check electron/textbundle.cjs && node --check electron/file-ops.cjs && node --check electron/theme-css.cjs && node --check electron/path-grants.cjs && node --check electron/document-types.cjs && node --check electron/document-reader.cjs && node --check electron/local-image-io.cjs && node --check electron/folder-dialog-hint.cjs", "check:native": "xcrun swiftc -typecheck -target arm64-apple-macos13.0 -framework AppKit -framework WebKit -framework UniformTypeIdentifiers native/Sources/*.swift", "test:native-security": "node scripts/test-native-security.mjs", "test:native-window-drag": "node scripts/test-native-window-drag.mjs", diff --git a/src/App.tsx b/src/App.tsx index 0340333..160b0d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { isExportFormat, rememberSuccessfulExport, type ExportFormat } from './l import { normalizeExportAppearance, resolveExportTheme } from './lib/exportPreferences' import { filterFileTreeByGlobs } from './lib/fileTreeFilters' import { normalizeCustomThemePreferences, observeSystemTheme, resolveCustomTheme, type CustomThemeAppearance } from './lib/customThemePreferences' +import { loadFolderSessionHint, saveFolderSessionHint } from './lib/folderSessionHint' const EMPTY_DOCUMENT = '' const PREF_KEY = 'typeopen.preferences.v1' @@ -334,11 +335,13 @@ export default function App() { }, [loadFile]) const openFolder = useCallback(async () => { - const result = await window.typeopen.invoke<{ folderPath: string; tree: TypeOpenTreeNode[] } | null>('typeopen:open-folder') + const hintPath = loadFolderSessionHint(localStorage) + const result = await window.typeopen.invoke<{ folderPath: string; tree: TypeOpenTreeNode[] } | null>('typeopen:open-folder', hintPath ? { hintPath } : undefined) if (!result) return const treeResult = preferences.showHiddenFiles ? await window.typeopen.invoke<{ folderPath: string; tree: TypeOpenTreeNode[] }>('typeopen:list-folder', { folderPath: result.folderPath, showHiddenFiles: true }) : result + saveFolderSessionHint(localStorage, result.folderPath) setFolderPath(result.folderPath) setFileTree(treeResult.tree) setSidebarVisible(true) diff --git a/src/lib/folderSessionHint.ts b/src/lib/folderSessionHint.ts new file mode 100644 index 0000000..7ec8772 --- /dev/null +++ b/src/lib/folderSessionHint.ts @@ -0,0 +1,26 @@ +export const FOLDER_SESSION_HINT_KEY = 'typeopen.folder-session-hint.v1' +export const MAX_FOLDER_SESSION_HINT_LENGTH = 4096 + +export function normalizeFolderSessionHint(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_FOLDER_SESSION_HINT_LENGTH || value.includes('\0')) return null + return value +} + +export function loadFolderSessionHint(storage: Pick): string | null { + try { + return normalizeFolderSessionHint(storage.getItem(FOLDER_SESSION_HINT_KEY)) + } catch { + return null + } +} + +export function saveFolderSessionHint(storage: Pick, folderPath: string): boolean { + const normalized = normalizeFolderSessionHint(folderPath) + if (!normalized) return false + try { + storage.setItem(FOLDER_SESSION_HINT_KEY, normalized) + return true + } catch { + return false + } +} diff --git a/tests/folder-session-hint.test.ts b/tests/folder-session-hint.test.ts new file mode 100644 index 0000000..36c6d6f --- /dev/null +++ b/tests/folder-session-hint.test.ts @@ -0,0 +1,40 @@ +import { createRequire } from 'node:module' +import { beforeEach, describe, expect, it } from 'vitest' +import { + FOLDER_SESSION_HINT_KEY, + loadFolderSessionHint, + normalizeFolderSessionHint, + saveFolderSessionHint +} from '../src/lib/folderSessionHint' + +const require = createRequire(import.meta.url) +const { folderDialogHintFromPayload, folderDialogOptions, normalizeFolderDialogHint } = require('../electron/folder-dialog-hint.cjs') as { + folderDialogHintFromPayload: (payload: unknown) => string | null + folderDialogOptions: (payload: unknown) => { properties: string[]; defaultPath?: string } + normalizeFolderDialogHint: (value: unknown) => string | null +} + +describe('folder session hint', () => { + beforeEach(() => localStorage.clear()) + + it('persists only a bounded opaque path string in renderer storage', () => { + expect(saveFolderSessionHint(localStorage, '/Users/test/Documents')).toBe(true) + expect(localStorage.getItem(FOLDER_SESSION_HINT_KEY)).toBe('/Users/test/Documents') + expect(loadFolderSessionHint(localStorage)).toBe('/Users/test/Documents') + expect(normalizeFolderSessionHint('')).toBeNull() + expect(normalizeFolderSessionHint('/tmp/bad\0hint')).toBeNull() + expect(normalizeFolderSessionHint('x'.repeat(4097))).toBeNull() + }) + + it('uses an absolute hint only as the Electron dialog starting location', () => { + expect(folderDialogOptions({ hintPath: '/Users/test/Documents' })).toEqual({ + properties: ['openDirectory'], + defaultPath: '/Users/test/Documents' + }) + expect(folderDialogOptions({ hintPath: 'relative/path' })).toEqual({ properties: ['openDirectory'] }) + expect(folderDialogOptions({ hintPath: '/Users/test/Documents', extra: true })).toEqual({ properties: ['openDirectory'] }) + expect(folderDialogOptions(['/Users/test/Documents'])).toEqual({ properties: ['openDirectory'] }) + expect(folderDialogHintFromPayload({ hintPath: '/Users/test/Documents' })).toBe('/Users/test/Documents') + expect(normalizeFolderDialogHint('/tmp/bad\0hint')).toBeNull() + }) +})