Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/PARITY_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions electron/folder-dialog-hint.cjs
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 4 additions & 3 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions native/Sources/NativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)]
Expand Down
7 changes: 7 additions & 0 deletions native/Tests/SecuritySmoke.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/lib/folderSessionHint.ts
Original file line number Diff line number Diff line change
@@ -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<Storage, 'getItem'>): string | null {
try {
return normalizeFolderSessionHint(storage.getItem(FOLDER_SESSION_HINT_KEY))
} catch {
return null
}
}

export function saveFolderSessionHint(storage: Pick<Storage, 'setItem'>, folderPath: string): boolean {
const normalized = normalizeFolderSessionHint(folderPath)
if (!normalized) return false
try {
storage.setItem(FOLDER_SESSION_HINT_KEY, normalized)
return true
} catch {
return false
}
}
40 changes: 40 additions & 0 deletions tests/folder-session-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading