Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b8dcea9
feat(web): add offline workspace stores
murongg Jul 13, 2026
321883b
fix(web): defer fake IndexedDB completion
murongg Jul 13, 2026
316448c
feat(web): persist browser workspaces
murongg Jul 13, 2026
9a0470c
fix(web): enforce workspace namespace integrity
murongg Jul 13, 2026
190f1b2
feat(web): add virtual workspace handles
murongg Jul 13, 2026
74b9c34
fix(web): preserve virtual workspace handle integrity
murongg Jul 13, 2026
7026cc1
fix(web): distinguish workspace repository failures
murongg Jul 13, 2026
1bcecb0
feat(web): add persistent offline workspaces
murongg Jul 13, 2026
9e17c29
fix(web): restore URL-encoded workspace files
murongg Jul 13, 2026
9ab8099
feat(app): open and export offline workspaces
murongg Jul 13, 2026
dae6f0f
test(app): cover workspace export and autosave wiring
murongg Jul 13, 2026
2e781e5
feat(web): add installable offline shell
murongg Jul 13, 2026
dcdfe19
fix(web): tighten offline shell boundaries
murongg Jul 13, 2026
df3dba3
fix(web): require complete offline shell precache
murongg Jul 13, 2026
e0e2874
docs(web): describe offline PWA storage
murongg Jul 13, 2026
aa78812
docs(web): clarify folder fallback trigger
murongg Jul 13, 2026
cd2c8e6
fix(web): harden offline workspace integrity
murongg Jul 13, 2026
4831a06
fix(web): make workspace creation exclusive
murongg Jul 13, 2026
7085edf
test(web): distinguish IndexedDB key ranges
murongg Jul 13, 2026
8c76e39
fix(web): render local workspace images
murongg Jul 13, 2026
a90c121
chore(merge): sync main into PWA workspace branch
murongg Jul 14, 2026
136acb0
fix(app): preserve edits while opening folders
murongg Jul 15, 2026
f38f273
fix(markdown): resolve forward-slash UNC image paths
murongg Jul 15, 2026
f2a859b
fix(app): keep folder switches transactional
murongg Jul 15, 2026
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,18 @@ Download the latest desktop builds from [GitHub Releases](https://github.com/mar
| Capability | Desktop app | Web editor |
| --- | --- | --- |
| WYSIWYG and source editing | Full editor experience | Full editor experience |
| Installation and offline use | Installed desktop app; local editing works offline | Installable PWA; after the first successful online load, the app shell and browser-local workspace can open offline |
| Open local files and folders | Native dialogs, file paths, and watchers | Browser file picker, folder picker, and file handles |
| Browser-local workspace | Not applicable; files stay on disk | Markdown files and attachments are stored in IndexedDB |
| Folder fallback | Opens folders in place | When direct directory picking is unavailable, opening a folder imports a persistent writable copy into the browser-local workspace |
| File tree operations | Create, rename, move, delete, sort, reveal, and multi-select | Create, rename, move, and delete where browser permissions allow |
| Auto-save and restore | Existing files, tabs, drafts, workspace windows | Browser file handles and IndexedDB state where available |
| AI providers | Native runtime requests with app proxy settings | Browser requests subject to provider CORS support |
| Offline limits | Local editing works offline; network-backed features require a connection | AI, WebDAV, Web search, remote images, and other network-backed features require a connection and are not Service Worker cached |
| Spellcheck | Markra-managed local spellcheck with on-demand language packs and a personal dictionary | Not available yet |
| Image storage | Local folders, WebDAV, PicGo/PicList, and S3-compatible storage | Local/browser handles and WebDAV where CORS permits |
| Backup and sync | Local backups and WebDAV sync | Not available in the web runtime |
| Export | HTML, PDF, and Pandoc formats when configured | HTML download and browser print/PDF |
| Export | HTML, PDF, and Pandoc formats when configured | HTML download, browser print/PDF, and ZIP for browser-local workspaces |

## Comparison

Expand Down
6 changes: 5 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,18 @@ brew install --cask markrahq/tap/markra
| 能力 | 桌面版 | Web 版 |
| --- | --- | --- |
| 所见即所得和源码编辑 | 完整编辑体验 | 完整编辑体验 |
| 安装与离线使用 | 安装后的桌面应用可离线编辑本地文件 | 可安装为 PWA;首次成功联网加载后,可离线打开应用外壳和浏览器本地工作区 |
| 打开本地文件和文件夹 | 原生文件对话框、真实路径和文件监听 | 浏览器文件选择、文件夹选择和文件句柄 |
| 浏览器本地工作区 | 不适用;文件保留在磁盘上 | Markdown 文件和附件存储在 IndexedDB 中 |
| 文件夹兼容模式 | 直接打开原文件夹 | 浏览器不支持直接选择目录时,打开文件夹会将其导入为浏览器本地工作区中的持久可写副本 |
| 文件树操作 | 新建、重命名、移动、删除、排序、定位和多选 | 在浏览器权限允许时新建、重命名、移动和删除 |
| 自动保存和状态恢复 | 已有文件、标签页、草稿和工作区窗口 | 支持浏览器文件句柄和 IndexedDB 状态时可用 |
| AI 服务商 | 通过原生运行时请求,支持应用代理设置 | 浏览器直接请求,受服务商 CORS 支持限制 |
| 离线限制 | 本地编辑可离线使用;联网功能仍需网络 | AI、WebDAV、联网搜索、远程图片等联网功能仍需网络,且不会被 Service Worker 缓存 |
| 拼写检查 | Markra 自维护本地拼写检查,支持按需下载语言包和个人白名单 | 暂不可用 |
| 图片存储 | 本地文件夹、WebDAV、PicGo/PicList 和 S3 兼容存储 | 本地/浏览器文件句柄,以及 CORS 允许时的 WebDAV |
| 备份与同步 | 本地备份和 WebDAV 同步 | Web 运行时不可用 |
| 导出 | HTML、PDF,以及配置 Pandoc 后的更多格式 | HTML 下载和浏览器打印/PDF |
| 导出 | HTML、PDF,以及配置 Pandoc 后的更多格式 | HTML 下载、浏览器打印/PDF,以及浏览器本地工作区 ZIP |

## 对照

Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/runtime/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ describe("desktop runtime logs", () => {
expect(desktopRuntime.logs.writeLog).toBe(logs.writeNativeLog);
});
});

describe("desktop file runtime workspace export contract", () => {
it("keeps virtual workspace export unavailable", async () => {
expect(desktopRuntime.files.canExportMarkdownFolder("web-workspace://default")).toBe(false);
await expect(desktopRuntime.files.exportMarkdownFolder("web-workspace://default")).resolves.toBeNull();
await expect(desktopRuntime.files.getDefaultMarkdownFolder()).resolves.toBeNull();
});
});
3 changes: 3 additions & 0 deletions apps/desktop/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const desktopRuntime = {
},
files: {
backupMarkdownFolder: files.backupNativeMarkdownFolder,
canExportMarkdownFolder: (_path: string) => false,
confirmMarkdownFileDelete: files.confirmNativeMarkdownFileDelete,
confirmUnsavedMarkdownDocumentDiscard: files.confirmNativeUnsavedMarkdownDocumentDiscard,
createMarkdownTreeFile: files.createNativeMarkdownTreeFile,
Expand All @@ -91,6 +92,8 @@ export const desktopRuntime = {
deleteMarkdownTreeFile: files.deleteNativeMarkdownTreeFile,
detectPandocPath: files.detectNativePandocPath,
downloadWebImage: files.downloadNativeWebImage,
exportMarkdownFolder: async (_path: string) => null,
getDefaultMarkdownFolder: async () => null,
installMarkdownFileDrop: files.installNativeMarkdownFileDrop,
importLocalFile: files.importNativeLocalFile,
listenOpenedMarkdownPaths: files.listenNativeOpenedMarkdownPaths,
Expand Down
7 changes: 5 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.app.json && vite build",
"build": "tsc -p tsconfig.app.json && vite build && pnpm verify:pwa",
"test": "vitest run",
"test:watch": "vitest",
"typecheck:test": "tsc -p tsconfig.test.json --noEmit"
"typecheck:test": "tsc -p tsconfig.test.json --noEmit",
"verify:pwa": "node scripts/verify-pwa.mjs"
},
"dependencies": {
"@markra/app": "workspace:*",
"@markra/editor": "workspace:*",
"@markra/shared": "workspace:*",
"fflate": "0.8.3",
"ignore": "7.0.5",
"react": "19.2.5",
"react-dom": "19.2.5"
Expand All @@ -30,6 +32,7 @@
"tailwindcss": "^4.2.4",
"typescript": "6.0.3",
"vite": "8.0.10",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.5"
}
}
Binary file added apps/web/public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
189 changes: 189 additions & 0 deletions apps/web/scripts/verify-pwa.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import { readdir, readFile, stat } from "node:fs/promises";

function matchCount(value, pattern) {
return [...value.matchAll(pattern)].length;
}

function extractArrayArgument(source, callName) {
const callMarker = `.${callName}(`;
const callIndex = source.indexOf(callMarker);
assert.notEqual(callIndex, -1, `${callName} call is missing`);

const arrayStart = source.indexOf("[", callIndex + callMarker.length);
assert.notEqual(arrayStart, -1, `${callName} array argument is missing`);

let depth = 0;
let escaped = false;
let inString = false;

for (let index = arrayStart; index < source.length; index += 1) {
const character = source[index];

if (inString) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') inString = false;
continue;
}

if (character === '"') inString = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) return source.slice(arrayStart, index + 1);
}
}

assert.fail(`${callName} array argument is not closed`);
}

async function listBuildFiles(directoryUrl, prefix = "") {
const entries = await readdir(directoryUrl, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
if (!entry.isDirectory()) return [path];

return listBuildFiles(new URL(`${entry.name}/`, directoryUrl), path);
}));

return files.flat();
}

const distUrl = new URL("../dist/", import.meta.url);
const manifest = JSON.parse(await readFile(new URL("manifest.webmanifest", distUrl), "utf8"));

assert.equal(manifest.name, "Markra");
assert.equal(manifest.short_name, "Markra");
assert.equal(manifest.description, "Local-first Markdown editor");
assert.equal(manifest.display, "standalone");
assert.equal(manifest.start_url, "/");
assert.equal(manifest.scope, "/");
assert.deepEqual(manifest.icons, [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }
]);

const expectedIcons = [
["icon-192.png", 192],
["icon-512.png", 512]
];

for (const [fileName, expectedSize] of expectedIcons) {
const icon = await readFile(new URL(fileName, distUrl));
assert.deepEqual(icon.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
assert.equal(icon.readUInt32BE(16), expectedSize, `${fileName} width`);
assert.equal(icon.readUInt32BE(20), expectedSize, `${fileName} height`);
}

await stat(new URL("sw.js", distUrl));
const serviceWorker = await readFile(new URL("sw.js", distUrl), "utf8");
assert.match(serviceWorker, /index\.html/);
assert.doesNotMatch(serviceWorker, /api[_/-]|webdav|authorization/iu);
assert.doesNotMatch(serviceWorker, /CacheFirst|CacheOnly|NetworkFirst|NetworkOnly|StaleWhileRevalidate/);
assert.doesNotMatch(serviceWorker, /\.clientsClaim\(/);
assert.match(serviceWorker, /"SKIP_WAITING"===.*&&self\.skipWaiting\(\)/);

assert.equal(
matchCount(serviceWorker, /(?<![\w$])precacheAndRoute\(/g),
1,
"Expected exactly one precacheAndRoute call"
);
const precacheArray = extractArrayArgument(serviceWorker, "precacheAndRoute");
const precacheUrlMatches = [...precacheArray.matchAll(/\{url:"((?:\\.|[^"\\])*)",revision:/g)];
assert.equal(
precacheUrlMatches.length,
matchCount(precacheArray, /\{url:/g),
"Unable to extract every precache URL"
);

const precacheUrls = precacheUrlMatches.map(([, encodedUrl]) => JSON.parse(`"${encodedUrl}"`));
assert.ok(precacheUrls.length > 0, "Precache URL list is empty");
const precacheUrlSet = new Set(precacheUrls);
assert.equal(precacheUrlSet.size, precacheUrls.length, "Precache URLs must be unique");

const requiredShellUrls = new Set([
"icon-192.png",
"icon-512.png",
"index.html",
"manifest.webmanifest",
"registerSW.js"
]);
for (const requiredUrl of requiredShellUrls) {
assert.ok(precacheUrlSet.has(requiredUrl), `Required shell URL is missing from precache: ${requiredUrl}`);
}

const staticAssetPath = /^assets\/[A-Za-z0-9._~!$&'()*+,;=:@/-]+$/;
const forbiddenPath =
/(?:^|[/_.-])(?:api|auth|authorization|documents?|uploads?|user[-_]?data|user[-_]?(?:docs?|documents?|files?)|webdav)(?:$|[/_.-])/iu;

for (const url of precacheUrls) {
assert.doesNotMatch(url, /^[a-z][a-z0-9+.-]*:/iu, `Precache URL must not use a scheme: ${url}`);
assert.doesNotMatch(url, /\/\//u, `Precache URL must not contain //: ${url}`);
assert.doesNotMatch(url, /[?#\\%]/u, `Precache URL must not contain query, hash, backslash, or encoding: ${url}`);
assert.ok(
!url.split("/").some((segment) => segment === "." || segment === ".."),
`Precache URL must not traverse: ${url}`
);
assert.doesNotMatch(url, forbiddenPath, `Precache URL must not target sensitive or user-document paths: ${url}`);
assert.ok(requiredShellUrls.has(url) || staticAssetPath.test(url), `Unexpected precache URL: ${url}`);
}

assert.equal(
matchCount(serviceWorker, /(?<![\w$])cleanupOutdatedCaches\(\)/g),
1,
"Expected cleanupOutdatedCaches exactly once"
);
assert.equal(matchCount(serviceWorker, /(?<![\w$])registerRoute\(/g), 1, "Expected exactly one runtime route");
assert.match(
serviceWorker,
/([A-Za-z_$][\w$]*)\.registerRoute\(new \1\.NavigationRoute\(\1\.createHandlerBoundToURL\("index\.html"\)\)\)/
);
assert.doesNotMatch(serviceWorker, /addEventListener\(["']fetch["']/u, "Custom fetch event listeners are not allowed");
assert.doesNotMatch(
serviceWorker,
/(?<![\w$])(?:self\.)?onfetch\s*=/u,
"Custom onfetch assignments are not allowed"
);
assert.doesNotMatch(
serviceWorker,
/\.(?:setCatchHandler|setDefaultHandler)\(/u,
"Custom default or catch handlers are not allowed"
);

const importScriptsCalls = [...serviceWorker.matchAll(/(?<![\w$])importScripts\(([^()]*)\)/g)];
assert.equal(importScriptsCalls.length, 1, "Expected exactly one dynamic Workbox importScripts call");
assert.match(importScriptsCalls[0][1].trim(), /^[A-Za-z_$][\w$]*$/, "Workbox importScripts argument must be a variable");
const workboxModuleDefinitions = [
...serviceWorker.matchAll(/define\(\["\.\/workbox-[\w-]+"\],function\([A-Za-z_$][\w$]*\)\{/g)
];
assert.equal(
workboxModuleDefinitions.length,
1,
"Expected exactly one Workbox-only module dependency"
);

const indexHtml = await readFile(new URL("index.html", distUrl), "utf8");
assert.match(indexHtml, /manifest\.webmanifest/);
assert.match(indexHtml, /registerSW\.js/);

const buildFiles = await listBuildFiles(distUrl);
assert.ok(buildFiles.some((fileName) => /^workbox-[\w-]+\.js$/.test(fileName)), "Workbox runtime is missing");

const eligibleStaticAsset = /\.(?:css|html|ico|jpg|js|png|svg|ttf|webp|woff|woff2)$/u;
const expectedPrecacheUrls = buildFiles.filter((fileName) =>
fileName === "manifest.webmanifest"
|| (
eligibleStaticAsset.test(fileName)
&& fileName !== "sw.js"
&& !/^workbox-[\w-]+\.js$/u.test(fileName)
)
);
for (const fileName of expectedPrecacheUrls) {
assert.ok(precacheUrlSet.has(fileName), `Eligible build asset is missing from precache: ${fileName}`);
}
for (const url of precacheUrls) {
assert.ok(buildFiles.includes(url), `Precache URL has no emitted build file: ${url}`);
}

console.log(`PWA artifacts verified: ${precacheUrls.length} unique shell/static precache URLs and one navigation fallback`);
6 changes: 6 additions & 0 deletions apps/web/src/runtime/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ describe("web runtime", () => {
expect(runtime.features.spellcheck).toBe(false);
expect(runtime.features.updater).toBe(false);
expect(runtime.platform.resolveDesktopPlatform()).toBe("windows");
await expect(runtime.files.getDefaultMarkdownFolder()).resolves.toEqual({
name: "Markra",
path: "web-workspace://default"
});
expect(runtime.files.canExportMarkdownFolder("web-workspace://default")).toBe(true);
expect(runtime.files.canExportMarkdownFolder("web-folder://external")).toBe(false);
await expect(runtime.updater.checkAppUpdate()).resolves.toBeNull();
});
});
4 changes: 3 additions & 1 deletion apps/web/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createWebMenuRuntime,
createWebResourceRuntime,
createWebWindowRuntime,
createWorkspaceRepository,
type WebRuntimeOptions
} from "./web";

Expand All @@ -20,6 +21,7 @@ export * from "./web";
export function createWebRuntime(options: WebRuntimeOptions = {}): AppRuntime {
const defaultRuntime = createDefaultAppRuntime();
const settings = createIndexedDbSettingsRuntime(options);
const workspaceRepository = createWorkspaceRepository(options);

return {
...defaultRuntime,
Expand All @@ -37,7 +39,7 @@ export function createWebRuntime(options: WebRuntimeOptions = {}): AppRuntime {
spellcheck: false,
updater: false
},
files: createWebFileRuntime(settings, options),
files: createWebFileRuntime(settings, workspaceRepository, options),
menu: createWebMenuRuntime(defaultRuntime.menu, options),
platform: {
resolveDesktopOsVersion: () => null,
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/runtime/web/database.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FakeIdbTransaction, FakeIndexedDbFactory } from "../../test/web-runtime-fakes";
import {
openWebRuntimeDatabase,
requestToPromise,
transactionToPromise,
webRuntimeAiChatAttachmentStoreName,
webRuntimeSettingsStoreName,
webRuntimeWorkspaceEntryStoreName,
webRuntimeWorkspaceStoreName
} from "./database";

describe("Web runtime database", () => {
it("adds workspace stores without losing existing Web runtime stores", async () => {
const factory = new FakeIndexedDbFactory();
const database = await openWebRuntimeDatabase({ indexedDB: factory.indexedDB });

expect(database.objectStoreNames.contains(webRuntimeSettingsStoreName)).toBe(true);
expect(database.objectStoreNames.contains(webRuntimeAiChatAttachmentStoreName)).toBe(true);
expect(database.objectStoreNames.contains(webRuntimeWorkspaceStoreName)).toBe(true);
expect(database.objectStoreNames.contains(webRuntimeWorkspaceEntryStoreName)).toBe(true);
});

it("waits for transaction completion and rejects transaction failures", async () => {
const transaction = new FakeIdbTransaction();
const completion = transactionToPromise(transaction as unknown as IDBTransaction);
transaction.fail(new DOMException("quota", "QuotaExceededError"));

await expect(completion).rejects.toMatchObject({ name: "QuotaExceededError" });
});

it("completes successful transactions after queued requests settle", async () => {
const factory = new FakeIndexedDbFactory();
const database = await openWebRuntimeDatabase({ indexedDB: factory.indexedDB });
const transaction = database.transaction(webRuntimeWorkspaceStoreName, "readonly");
const store = transaction.objectStore(webRuntimeWorkspaceStoreName);
const events: string[] = [];

const record = await requestToPromise(store.get("workspace-1"));
events.push("request");
await transactionToPromise(transaction);
events.push("transaction");

expect(record).toBeUndefined();
expect(events).toEqual(["request", "transaction"]);
}, 100);
});
Loading
Loading