Skip to content

Commit 72095f3

Browse files
authored
use IDBFS to persist data (#75)
1 parent 37adcb7 commit 72095f3

File tree

8 files changed

+101
-15
lines changed

8 files changed

+101
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The project provides an npm package `fcitx5-js.tgz`, which powers derivative app
66

77
Derivative | Note
88
-|-
9-
[Fcitx5 Online](https://github.com/fcitx-contrib/fcitx5-online) | [Preview](https://fcitx-contrib.github.io/online/)
9+
[Fcitx5 Online](https://github.com/fcitx/fcitx5-online) | [Preview](https://fcitx-contrib.github.io/online/)
1010
[Fcitx5 Chrome](https://github.com/fcitx-contrib/fcitx5-chrome) |
1111

1212
## Build

page/Fcitx5.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,17 @@ interface FS {
3939
lstat: (path: string) => { mode: number }
4040
mkdir: (path: string) => void
4141
mkdirTree: (path: string) => void
42+
mount: (type: any, opts: { autoPersist?: boolean }, mountpoint: string) => void
4243
readFile: {
4344
(path: string): Uint8Array
4445
(path: string, options: { encoding: 'utf8' }): string
4546
}
4647
readdir: (path: string) => string[]
4748
rmdir: (path: string) => void
49+
symlink: (target: string, path: string) => void
50+
syncfs: (populate: boolean, callback: (err: any) => void) => void
4851
unlink: (path: string) => void
49-
writeFile: (path: string, data: Uint8Array) => void
52+
writeFile: (path: string, data: Uint8Array | string) => void
5053
}
5154

5255
type WASM_TYPE = 'void' | 'bool' | 'number' | 'string'
@@ -56,6 +59,7 @@ export interface EM_MODULE {
5659
locateFile: (file: string) => string
5760
onRuntimeInitialized: () => void
5861
FS: FS
62+
IDBFS: any
5963
}
6064

6165
export type SyncCallback = (path: string) => void
@@ -106,6 +110,7 @@ export interface FCITX {
106110
utf8Index2JS: (text: string, index: number) => number
107111
setNotificationCallback: (callback: NotificationCallback) => void
108112
notify: NotificationCallback
113+
reset: () => Promise<any>
109114
Module: EM_MODULE
110115
}
111116

page/fs.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { AsyncCallback, SyncCallback } from './Fcitx5'
22
import Module from './module'
33

4+
export const USR_MOUNT_POINT = '/backup/usr'
5+
export const HOME_MOUNT_POINT = '/home/web_user'
6+
47
export function lsDir(path: string) {
58
const names = Module.FS.readdir(path)
69
return names.filter(name => name !== '.' && name !== '..')
710
}
811

9-
function traverseSync(preDirCallback: SyncCallback | undefined, fileCallback: SyncCallback, postDirCallback: SyncCallback | undefined) {
12+
export function traverseSync(preDirCallback: SyncCallback | undefined, fileCallback: SyncCallback, postDirCallback: SyncCallback | undefined) {
1013
async function closure(path: string) {
1114
const { mode } = Module.FS.lstat(path)
1215
if (Module.FS.isDir(mode)) {
@@ -49,3 +52,19 @@ export function traverseAsync(preDirCallback: AsyncCallback | undefined, fileCal
4952
}
5053
return closure
5154
}
55+
56+
export function mount() {
57+
// Don't mount /usr directly as it stores unnecessary files and is too slow.
58+
Module.FS.mkdirTree(USR_MOUNT_POINT)
59+
Module.FS.mount(Module.IDBFS, { autoPersist: true }, USR_MOUNT_POINT)
60+
Module.FS.mount(Module.IDBFS, { autoPersist: true }, HOME_MOUNT_POINT)
61+
}
62+
63+
export function reset() {
64+
const { promise, resolve } = Promise.withResolvers<void>()
65+
rmR(USR_MOUNT_POINT)
66+
rmR(HOME_MOUNT_POINT)
67+
// Manually trigger syncfs to ensure data is cleared.
68+
Module.FS.syncfs(false, () => resolve())
69+
return promise
70+
}

page/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,17 @@ import { getAddons, getConfig, setConfig } from './config'
66
import { SERVICE_WORKER, WEB, WEB_WORKER } from './constant'
77
import { hasTouch, isFirefox } from './context'
88
import { blur, clickPanel, focus, isInputElement, redrawCaretAndPreeditUnderline } from './focus'
9-
import { rmR, traverseAsync } from './fs'
9+
import { mount, reset, rmR, traverseAsync } from './fs'
1010
import { currentInputMethod, getAllInputMethods, getInputMethods, getLanguageName, setCurrentInputMethod, setInputMethods } from './input-method'
1111
import { createKeyboard, sendEventToKeyboard } from './keyboard'
1212
import { jsKeyToFcitxString, keyEvent } from './keycode'
1313
import { getLocale } from './locale'
1414
import Module from './module'
15-
import { getInstalledPlugins, installPlugin, unzip } from './plugin'
15+
import { getInstalledPlugins, installPlugin, restorePlugins, unzip } from './plugin'
1616
import { utf8Index2JS } from './unicode'
1717
import { deployRimeInWorker } from './workerAPI'
1818

19-
let res: (value: any) => void
20-
21-
const fcitxReady = new Promise((resolve) => {
22-
res = resolve
23-
})
19+
const { promise: fcitxReady, resolve } = Promise.withResolvers()
2420

2521
let inputMethodsCallback = () => {}
2622
let statusAreaCallback = () => {}
@@ -148,6 +144,7 @@ globalThis.fcitx = {
148144
rmR,
149145
traverseAsync,
150146
deployRimeInWorker,
147+
reset,
151148
// Private field that indicates whether spawn a worker in current environment.
152149
// On f5o main thread set true to enable worker. On worker thread this is always false.
153150
useWorker: false,
@@ -171,7 +168,13 @@ for (const api of apis) {
171168
globalThis.fcitx[name] = (...args: any[]) => Module.ccall('web_action', 'void', ['string', 'string'], [name, JSON.stringify(args)])
172169
}
173170

174-
Module.onRuntimeInitialized = () => res(null)
171+
Module.onRuntimeInitialized = () => {
172+
mount()
173+
Module.FS.syncfs(true, () => {
174+
restorePlugins()
175+
resolve(null)
176+
})
177+
}
175178

176179
export {
177180
fcitxReady,

page/plugin.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import UZIP from 'uzip'
22
import { hasTouch } from './context'
3-
import { lsDir } from './fs'
3+
import { lsDir, traverseSync, USR_MOUNT_POINT } from './fs'
44
import { getLocale } from './locale'
55
import Module from './module'
66

@@ -28,6 +28,21 @@ function distributeFiles(manifest: UZIP.UZIPFiles, dir: string) {
2828
})
2929
}
3030

31+
function symlink(manifest: UZIP.UZIPFiles) {
32+
Object.entries(manifest).forEach(([path]) => {
33+
const absolutePath = `/usr/${path}`
34+
if (path.endsWith('/')) {
35+
Module.FS.mkdirTree(absolutePath)
36+
}
37+
else {
38+
try {
39+
Module.FS.symlink(`${USR_MOUNT_POINT}/${path}`, absolutePath)
40+
}
41+
catch {}
42+
}
43+
})
44+
}
45+
3146
export function unzip(buffer: ArrayBuffer, dir: string) {
3247
// UZIP hangs on empty zip.
3348
if (!buffer.byteLength) {
@@ -43,7 +58,7 @@ export function installPlugin(buffer: ArrayBuffer) {
4358
throw new Error('Invalid plugin')
4459
}
4560
const manifest = UZIP.parse(buffer)
46-
const names = Object.keys(manifest).map(path => (path.match(/^plugin\/(\S+)\.json$/) ?? { 1: undefined })[1]).filter(name => name !== undefined)
61+
const names = Object.keys(manifest).flatMap(path => (path.match(/^plugin\/(\S+)\.json$/) ?? { 1: [] })[1])
4762
if (names.length !== 1) {
4863
throw new Error('Invalid plugin')
4964
}
@@ -52,12 +67,29 @@ export function installPlugin(buffer: ArrayBuffer) {
5267
if (!byteArray) {
5368
throw new Error('Invalid plugin')
5469
}
55-
distributeFiles(manifest, '/usr')
70+
// Extract plugin to /backup/usr so that they are stored in IDBFS.
71+
distributeFiles(manifest, USR_MOUNT_POINT)
72+
// Symlink one plugin's files to /usr.
73+
symlink(manifest)
5674
reload()
5775
addDefaultIMs(byteArray)
5876
return name
5977
}
6078

79+
// Symlink all plugins' files from /backup/usr to /usr.
80+
export function restorePlugins() {
81+
traverseSync((backupPath) => {
82+
const path = `/usr${backupPath.slice(USR_MOUNT_POINT.length)}`
83+
Module.FS.mkdirTree(path)
84+
}, (backupPath) => {
85+
const path = `/usr${backupPath.slice(USR_MOUNT_POINT.length)}`
86+
try {
87+
Module.FS.symlink(backupPath, path)
88+
}
89+
catch {}
90+
}, undefined)(USR_MOUNT_POINT)
91+
}
92+
6193
export function getInstalledPlugins() {
6294
try {
6395
const files = lsDir('/usr/plugin')

playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const mobileTests = /.*\.mobile\.spec\.ts/
44

55
export default defineConfig({
66
testDir: 'tests',
7+
retries: 3,
78
projects: [{
89
name: 'Chromium',
910
use: {

src/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ set(EXE_LINK_OPTIONS
1919
"-sSTACK_SIZE=1MB" # fcitx5-chewing's log overflows the default 64KB stack
2020
"-sALLOW_MEMORY_GROWTH=1"
2121
"-sMAXIMUM_MEMORY=4GB"
22-
"-sEXPORTED_RUNTIME_METHODS=['ccall','FS']"
22+
"-sEXPORTED_RUNTIME_METHODS=['ccall','FS','IDBFS']"
2323
"-sENVIRONMENT=web,worker" # disable require("ws") in Fcitx5.js which is rejected by project user's bundler
2424
"--extern-pre-js ${PROJECT_BINARY_DIR}/pre.js"
2525
"--extern-post-js ${PROJECT_BINARY_DIR}/index.js"
2626
"-L${PROJECT_BINARY_DIR}/destdir/usr/lib"
27+
"-lidbfs.js"
2728
Fcitx5::Core
2829
)
2930

tests/test-idbfs.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Page } from '@playwright/test'
2+
import { expect, test } from '@playwright/test'
3+
import { init } from './util'
4+
5+
async function readTextFile(page: Page, path: string): Promise<string> {
6+
return page.evaluate(({ path }) => window.fcitx.Module.FS.readFile(path, { encoding: 'utf8' }), { path })
7+
}
8+
9+
test('IDBFS works', async ({ page }) => {
10+
await init(page)
11+
12+
expect(await page.evaluate(() => window.fcitx.Module.FS.readdir('/backup')), '/backup/usr is automatically created').toEqual(['.', '..', 'usr'])
13+
14+
await page.evaluate(() => window.fcitx.Module.FS.mkdirTree('/backup/usr/local'))
15+
await page.evaluate(() => window.fcitx.Module.FS.writeFile('/backup/usr/local/foo.txt', 'bar'))
16+
await page.evaluate(() => window.fcitx.Module.FS.writeFile('/home/web_user/bar.txt', 'baz'))
17+
await init(page)
18+
expect(await readTextFile(page, '/backup/usr/local/foo.txt'), 'Data persists after reload').toBe('bar')
19+
expect(await readTextFile(page, '/home/web_user/bar.txt')).toBe('baz')
20+
21+
await page.evaluate(() => window.fcitx.reset())
22+
await init(page)
23+
expect(await page.evaluate(() => window.fcitx.Module.FS.readdir('/backup/usr')), 'Data cleared after reset').toEqual(['.', '..'])
24+
expect(await page.evaluate(() => window.fcitx.Module.FS.readdir('/home/web_user'))).toEqual(['.', '..', '.config'])
25+
})

0 commit comments

Comments
 (0)