Skip to content

Commit d78d4da

Browse files
JohnMcLearclaude
andauthored
feat(mobile): real persistence via @capacitor/preferences (Phase 4) (#34)
* docs(plan): add phase-4 storage implementation plan * chore(mobile): add @capacitor/preferences + @shared alias * feat(mobile): add storage layer (preferences wrapper + workspace/padHistory/settings stores) * feat(mobile): wire CapacitorPlatform to the storage layer + add persistence smoke test state.getInitial/workspace.*/padHistory.*/settings.* now read+write through @capacitor/preferences via the new stores. Errors funnel through `wrap()` into typed `{ ok: false, error }` envelopes that the shell already knows how to unwrap into AppError. Adds a Playwright test that pre-seeds localStorage with a Capacitor Preferences-shaped workspace blob, loads the page, and asserts the workspace renders in the rail (and AddWorkspaceDialog is hidden) — end-to-end coverage of the read path through the Zod-validated store. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): cast Zod-inferred entries to PadHistoryEntry[] at store boundary exactOptionalPropertyTypes treats title?: string and title?: string | undefined as distinct; Zod's .optional() emits the latter. Same boundary cast as desktop's pad-history-store. Local typecheck slipped because of stale tsbuildinfo cache; cleaning out/ surfaces the error and CI catches it too. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d07547 commit d78d4da

11 files changed

Lines changed: 804 additions & 49 deletions

File tree

docs/superpowers/plans/2026-05-11-etherpad-mobile-phase4-storage.md

Lines changed: 472 additions & 0 deletions
Large diffs are not rendered by default.

packages/mobile/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
"@capacitor/android": "^8.0.0",
2222
"@capacitor/app": "^8.0.0",
2323
"@capacitor/core": "^8.0.0",
24+
"@capacitor/preferences": "^8.0.1",
2425
"@etherpad/shell": "workspace:*",
2526
"react": "^19.2.0",
26-
"react-dom": "^19.2.0"
27+
"react-dom": "^19.2.0",
28+
"zod": "^4.4.3"
2729
},
2830
"devDependencies": {
2931
"@capacitor/cli": "^8.0.0",

packages/mobile/src/platform/capacitor.ts

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,72 @@
11
import type { Platform } from '@etherpad/shell';
2+
import * as workspaceStore from './storage/workspace-store.js';
3+
import * as padHistoryStore from './storage/pad-history-store.js';
4+
import * as settingsStore from './storage/settings-store.js';
25

36
/**
4-
* Stub `CapacitorPlatform` for Phase 3. Returns empty state for every read
5-
* so the shell renders its first-launch UI (the non-dismissable
6-
* `AddWorkspaceDialog`). Write methods reject with NOT_IMPLEMENTED — Phase 4
7-
* replaces these with `@capacitor/preferences` + `@capacitor/filesystem`.
7+
* Concrete `Platform` impl for mobile. Workspace, pad-history, and settings
8+
* persistence go through `@capacitor/preferences` (web fallback = localStorage
9+
* with `CapacitorStorage.<key>` prefix). Pad rendering (`tab.*`),
10+
* desktop-specific surfaces (`httpLogin`, `updater`), and pad-content search
11+
* remain stubbed — they land in Phase 5+ alongside `PadIframeStack` and the
12+
* deep-links / share / permissions plugin.
813
*
9-
* Events are no-op subscribers: the unsubscribe fn does nothing because
10-
* nothing ever fires. Mobile is single-window so cross-process events aren't
11-
* meaningful; Phase 4 may add an in-process mitt bus if components want to
12-
* fire local events.
13-
*
14-
* All reads that flow through `ipc.ts`'s `unwrap()` helper return the
15-
* `{ ok: true, value: ... }` IPC envelope shape. Methods that bypass
16-
* unwrap (`updater.getState`, `quickSwitcher.searchPadContent`, the events)
17-
* return raw values per the shell's type contract.
14+
* All write paths funnel through `wrap()` so a thrown error inside a store
15+
* surfaces to the shell as a typed `{ ok: false, error: ... }` envelope
16+
* (consumed by `ipc.ts`'s `unwrap()`).
1817
*/
1918
export function createCapacitorPlatform(): Platform {
2019
const ok = Promise.resolve({ ok: true });
21-
const okValue = <T>(value: T) => Promise.resolve({ ok: true, value });
22-
const notImpl = (op: string) =>
23-
Promise.reject(new Error(`[mobile/Phase 3] ${op} not implemented yet`));
24-
const noopUnsubscribe = (): (() => void) => () => {};
25-
26-
const defaultSettings = {
27-
schemaVersion: 1 as const,
28-
defaultZoom: 1,
29-
accentColor: '#3366cc',
30-
language: 'en',
31-
rememberOpenTabsOnQuit: true,
32-
minimizeToTray: false,
33-
themePreference: 'auto' as const,
34-
userName: '',
20+
const wrap = async <T>(
21+
fn: () => Promise<T>,
22+
): Promise<{ ok: true; value: T } | { ok: false; error: { kind: string; message: string } }> => {
23+
try {
24+
const value = await fn();
25+
return { ok: true, value };
26+
} catch (err) {
27+
return {
28+
ok: false,
29+
error: {
30+
kind: 'UnknownError',
31+
message: err instanceof Error ? err.message : String(err),
32+
},
33+
};
34+
}
3535
};
36+
const notImpl = (
37+
op: string,
38+
): Promise<{ ok: false; error: { kind: string; message: string } }> =>
39+
Promise.resolve({
40+
ok: false,
41+
error: { kind: 'NotImplementedError', message: `[mobile] ${op} not implemented yet` },
42+
});
43+
const noopUnsubscribe = (): (() => void) => () => {};
3644

3745
return {
3846
state: {
3947
getInitial: () =>
40-
okValue({
41-
workspaces: [],
42-
workspaceOrder: [],
43-
settings: defaultSettings,
44-
padHistory: {},
48+
wrap(async () => {
49+
const { workspaces, order } = await workspaceStore.list();
50+
const settings = await settingsStore.get();
51+
const padHistory = await padHistoryStore.loadAll(order);
52+
return {
53+
workspaces,
54+
workspaceOrder: order,
55+
settings,
56+
padHistory,
57+
};
4558
}),
4659
},
4760
workspace: {
48-
list: () => okValue({ workspaces: [], order: [] }),
49-
add: () => notImpl('workspace.add'),
50-
update: () => notImpl('workspace.update'),
51-
remove: () => notImpl('workspace.remove'),
52-
reorder: () => notImpl('workspace.reorder'),
61+
list: () => wrap(workspaceStore.list),
62+
add: (input) => wrap(() => workspaceStore.add(input)),
63+
update: (input) => wrap(() => workspaceStore.update(input)),
64+
remove: (input) =>
65+
wrap(async () => {
66+
await workspaceStore.remove(input);
67+
return { ok: true } as const;
68+
}),
69+
reorder: (input) => wrap(() => workspaceStore.reorder(input)),
5370
},
5471
tab: {
5572
open: () => notImpl('tab.open'),
@@ -68,15 +85,32 @@ export function createCapacitorPlatform(): Platform {
6885
setRailCollapsed: () => ok,
6986
},
7087
padHistory: {
71-
list: () => okValue([]),
72-
pin: () => notImpl('padHistory.pin'),
73-
unpin: () => notImpl('padHistory.unpin'),
74-
clearRecent: () => notImpl('padHistory.clearRecent'),
75-
clearAll: () => notImpl('padHistory.clearAll'),
88+
list: (input) => wrap(() => padHistoryStore.list(input)),
89+
pin: (input) =>
90+
wrap(async () => {
91+
await padHistoryStore.pin(input);
92+
return { ok: true } as const;
93+
}),
94+
unpin: (input) =>
95+
wrap(async () => {
96+
await padHistoryStore.unpin(input);
97+
return { ok: true } as const;
98+
}),
99+
clearRecent: (input) =>
100+
wrap(async () => {
101+
await padHistoryStore.clearRecent(input);
102+
return { ok: true } as const;
103+
}),
104+
clearAll: () =>
105+
wrap(async () => {
106+
await padHistoryStore.clearAll();
107+
return { ok: true } as const;
108+
}),
76109
},
77110
settings: {
78-
get: () => okValue(defaultSettings),
79-
update: () => notImpl('settings.update'),
111+
get: () => wrap(settingsStore.get),
112+
update: (patch) =>
113+
wrap(() => settingsStore.update(patch as Parameters<typeof settingsStore.update>[0])),
80114
},
81115
httpLogin: {
82116
respond: () => notImpl('httpLogin.respond'),
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { PadHistoryEntry } from '@shared/types/pad-history';
2+
import { padHistoryFileSchema } from '@shared/validation/pad-history';
3+
import { listKeys, loadJson, removeKey, saveJson } from './preferences.js';
4+
5+
const PREFIX = 'etherpad:padHistory:';
6+
const keyFor = (workspaceId: string): string => `${PREFIX}${workspaceId}`;
7+
8+
async function load(workspaceId: string): Promise<PadHistoryEntry[]> {
9+
const file = await loadJson(keyFor(workspaceId), padHistoryFileSchema);
10+
// Cast at the boundary: Zod's `.optional()` infers `title: string | undefined`
11+
// whereas `PadHistoryEntry.title` is `title?: string` (no `| undefined`)
12+
// under `exactOptionalPropertyTypes: true`. Same boundary cast as desktop's
13+
// pad-history-store.
14+
return (file?.entries ?? []) as PadHistoryEntry[];
15+
}
16+
17+
async function save(workspaceId: string, entries: PadHistoryEntry[]): Promise<void> {
18+
await saveJson(keyFor(workspaceId), padHistoryFileSchema, { schemaVersion: 1, entries });
19+
}
20+
21+
export async function list(input: { workspaceId: string }): Promise<PadHistoryEntry[]> {
22+
return load(input.workspaceId);
23+
}
24+
25+
export async function pin(input: { workspaceId: string; padName: string }): Promise<void> {
26+
const entries = await load(input.workspaceId);
27+
const e = entries.find((x) => x.padName === input.padName);
28+
if (e) e.pinned = true;
29+
await save(input.workspaceId, entries);
30+
}
31+
32+
export async function unpin(input: { workspaceId: string; padName: string }): Promise<void> {
33+
const entries = await load(input.workspaceId);
34+
const e = entries.find((x) => x.padName === input.padName);
35+
if (e) e.pinned = false;
36+
await save(input.workspaceId, entries);
37+
}
38+
39+
export async function clearRecent(input: { workspaceId: string }): Promise<void> {
40+
const entries = (await load(input.workspaceId)).filter((e) => e.pinned);
41+
await save(input.workspaceId, entries);
42+
}
43+
44+
export async function clearAll(): Promise<void> {
45+
const keys = await listKeys();
46+
for (const key of keys) {
47+
if (key.startsWith(PREFIX)) await removeKey(key);
48+
}
49+
}
50+
51+
/** Used by `state.getInitial()` to bundle history for every known workspace. */
52+
export async function loadAll(workspaceIds: string[]): Promise<Record<string, PadHistoryEntry[]>> {
53+
const result: Record<string, PadHistoryEntry[]> = {};
54+
for (const id of workspaceIds) result[id] = await load(id);
55+
return result;
56+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Preferences } from '@capacitor/preferences';
2+
import type { ZodTypeAny, infer as zInfer } from 'zod';
3+
4+
/**
5+
* Read a Preferences key and parse it with the given Zod schema. Returns
6+
* `null` when the key is absent or the stored JSON doesn't match the
7+
* schema. Callers fall back to a default in that case rather than throw —
8+
* preferable to crashing the app on boot if disk data is malformed.
9+
*/
10+
export async function loadJson<S extends ZodTypeAny>(
11+
key: string,
12+
schema: S,
13+
): Promise<zInfer<S> | null> {
14+
const { value } = await Preferences.get({ key });
15+
if (value === null) return null;
16+
try {
17+
const parsed = JSON.parse(value) as unknown;
18+
const result = schema.safeParse(parsed);
19+
return result.success ? (result.data as zInfer<S>) : null;
20+
} catch {
21+
return null;
22+
}
23+
}
24+
25+
export async function saveJson<S extends ZodTypeAny>(
26+
key: string,
27+
schema: S,
28+
value: zInfer<S>,
29+
): Promise<void> {
30+
// Validate before persisting so we never write a malformed blob.
31+
schema.parse(value);
32+
await Preferences.set({ key, value: JSON.stringify(value) });
33+
}
34+
35+
export async function removeKey(key: string): Promise<void> {
36+
await Preferences.remove({ key });
37+
}
38+
39+
export async function listKeys(): Promise<string[]> {
40+
const { keys } = await Preferences.keys();
41+
return keys;
42+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Settings } from '@shared/types/settings';
2+
import { defaultSettings, settingsSchema } from '@shared/validation/settings';
3+
import { loadJson, saveJson } from './preferences.js';
4+
5+
const KEY = 'etherpad:settings';
6+
7+
export async function get(): Promise<Settings> {
8+
const stored = await loadJson(KEY, settingsSchema);
9+
return stored ?? defaultSettings;
10+
}
11+
12+
export async function update(patch: Partial<Settings>): Promise<Settings> {
13+
const current = await get();
14+
const next: Settings = { ...current, ...patch, schemaVersion: 1 };
15+
await saveJson(KEY, settingsSchema, next);
16+
return next;
17+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Workspace } from '@shared/types/workspace';
2+
import { workspacesFileSchema } from '@shared/validation/workspace';
3+
import { loadJson, saveJson } from './preferences.js';
4+
5+
const KEY = 'etherpad:workspaces';
6+
7+
export interface WorkspacesFile {
8+
schemaVersion: 1;
9+
workspaces: Workspace[];
10+
order: string[];
11+
}
12+
13+
async function load(): Promise<WorkspacesFile> {
14+
const file = await loadJson(KEY, workspacesFileSchema);
15+
return file ?? { schemaVersion: 1, workspaces: [], order: [] };
16+
}
17+
18+
async function save(file: WorkspacesFile): Promise<void> {
19+
await saveJson(KEY, workspacesFileSchema, file);
20+
}
21+
22+
export async function list(): Promise<{ workspaces: Workspace[]; order: string[] }> {
23+
const file = await load();
24+
return { workspaces: file.workspaces, order: file.order };
25+
}
26+
27+
export async function add(input: {
28+
name: string;
29+
serverUrl?: string;
30+
color: string;
31+
kind?: 'remote' | 'embedded';
32+
}): Promise<Workspace> {
33+
const file = await load();
34+
const ws: Workspace = {
35+
id: crypto.randomUUID(),
36+
name: input.name,
37+
serverUrl: input.serverUrl ?? '',
38+
color: input.color,
39+
createdAt: Date.now(),
40+
...(input.kind ? { kind: input.kind } : {}),
41+
};
42+
file.workspaces.push(ws);
43+
file.order.push(ws.id);
44+
await save(file);
45+
return ws;
46+
}
47+
48+
export async function update(input: {
49+
id: string;
50+
name?: string;
51+
serverUrl?: string;
52+
color?: string;
53+
}): Promise<Workspace> {
54+
const file = await load();
55+
const ws = file.workspaces.find((w) => w.id === input.id);
56+
if (!ws) throw new Error(`Workspace ${input.id} not found`);
57+
if (input.name !== undefined) ws.name = input.name;
58+
if (input.serverUrl !== undefined) ws.serverUrl = input.serverUrl;
59+
if (input.color !== undefined) ws.color = input.color;
60+
await save(file);
61+
return ws;
62+
}
63+
64+
export async function remove(input: { id: string }): Promise<void> {
65+
const file = await load();
66+
file.workspaces = file.workspaces.filter((w) => w.id !== input.id);
67+
file.order = file.order.filter((id) => id !== input.id);
68+
await save(file);
69+
}
70+
71+
export async function reorder(input: { order: string[] }): Promise<string[]> {
72+
const file = await load();
73+
const known = new Set(file.workspaces.map((w) => w.id));
74+
for (const id of input.order) {
75+
if (!known.has(id)) throw new Error(`Unknown workspace ${id}`);
76+
}
77+
file.order = input.order;
78+
await save(file);
79+
return input.order;
80+
}

0 commit comments

Comments
 (0)