Merge branch 'feature/gitmost-audio-bridge' into develop

Expose window.gitmost native bridge for inserting recorded audio as audio blocks.
This commit is contained in:
claude_code
2026-06-23 02:24:39 +03:00
2 changed files with 185 additions and 1 deletions

View File

@@ -65,7 +65,9 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
@@ -86,6 +88,71 @@ interface PageEditorProps {
canComment?: boolean;
}
// --- gitmost native bridge ------------------------------------------------
// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
// WKWebView) to insert a recorded audio file into the current page as an
// `audio` block, without depending on editor internals (atoms/Tiptap/Yjs).
interface GitmostInsertRecordingPayload {
base64: string; // raw file bytes, base64 (no data: prefix)
filename: string;
mimeType: string; // must be an audio/* type
}
interface GitmostInsertRecordingResult {
ok: boolean;
attachmentId?: string;
// Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
error?: string;
message?: string; // human-readable, may be surfaced by the host
}
interface GitmostBridge {
ready: boolean;
version: number;
insertRecording: (
payload: GitmostInsertRecordingPayload,
) => Promise<GitmostInsertRecordingResult>;
}
// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
// oversized payload can be rejected before the buffer is allocated.
function gitmostEstimateBase64Bytes(base64: string): number {
const len = base64.length;
if (len === 0) return 0;
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.floor((len * 3) / 4) - padding;
}
// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
// with no embedded whitespace (per the native-host contract). Throws
// InvalidCharacterError on malformed input.
function gitmostBase64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
const parts: Uint8Array[] = [];
let total = 0;
for (let i = 0; i < base64.length; i += CHUNK) {
const binary = atob(base64.slice(i, i + CHUNK));
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) {
bytes[j] = binary.charCodeAt(j);
}
parts.push(bytes);
total += bytes.length;
}
// Back the result with an explicit ArrayBuffer so the view is typed
// Uint8Array<ArrayBuffer> (not ArrayBufferLike), which `new File([...])`
// accepts as a BlobPart under the lib.dom typings.
const out = new Uint8Array(new ArrayBuffer(total));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
export default function PageEditor({
pageId,
editable,
@@ -354,6 +421,118 @@ export default function PageEditor({
},
});
// Expose the gitmost native bridge only while an editable page editor is
// mounted. Registering/tearing down here ties `ready` + `insertRecording`
// to the lifetime of the current editable editor: readonly/share pages and
// page switches re-run this effect (deps: live editable flag + pageId),
// recreating the closure over the active editor/pageId so a recording always
// targets whatever page is active at call time.
useEffect(() => {
if (!editor || !editor.isEditable) return;
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
w.gitmost = w.gitmost || {};
w.gitmost.version = 1;
w.gitmost.ready = true;
const insertRecording = async (
payload: GitmostInsertRecordingPayload,
): Promise<GitmostInsertRecordingResult> => {
try {
const { filename, mimeType } = payload || ({} as GitmostInsertRecordingPayload);
let base64 = payload?.base64;
// Only a live, editable editor may receive a recording.
if (!editor || editor.isDestroyed || !editor.isEditable) {
return { ok: false, error: "no-editor", message: "No editable page open" };
}
if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
return { ok: false, error: "bad-type", message: "Not an audio file" };
}
if (typeof base64 !== "string" || base64.length === 0) {
return { ok: false, error: "insert-failed", message: "Empty payload" };
}
// Defensively strip an accidental data:*;base64, prefix.
const marker = base64.indexOf("base64,");
if (base64.startsWith("data:") && marker !== -1) {
base64 = base64.slice(marker + "base64,".length);
}
const sizeLimit = getFileUploadSizeLimit();
// Reject oversized payloads before allocating the decode buffer.
if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
return {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
};
}
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = gitmostBase64ToBytes(base64);
} catch (decodeErr: any) {
return {
ok: false,
error: "insert-failed",
message: decodeErr?.message ?? "Invalid base64 payload",
};
}
const file = new File([bytes], filename || "recording", { type: mimeType });
// Exact size check (the pre-decode estimate is approximate).
if (file.size > sizeLimit) {
return {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
};
}
// Insert at the cursor, falling back to the end of the document.
const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
// Reuse the existing audio pipeline (placeholder -> POST /api/files/upload
// -> replace with an `audio` node, Yjs-synced). It returns the attachment
// on success and undefined when the upload failed (the pipeline swallows
// the upload error and shows its own notification).
const attachment = (await (uploadAudioAction(
file,
editor,
pos,
pageId,
) as unknown as Promise<{ id?: string } | undefined>));
if (attachment?.id) {
return { ok: true, attachmentId: attachment.id };
}
return { ok: false, error: "insert-failed", message: "Upload failed" };
} catch (err: any) {
// The bridge must never throw — surface any unexpected failure as a code.
return {
ok: false,
error: "insert-failed",
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
};
}
};
w.gitmost.insertRecording = insertRecording;
return () => {
// Only tear down if our registration is still the active one. With
// React's mount-before-unmount ordering, a newer PageEditor instance may
// have already replaced the bridge; clearing it here would disable the
// live editor's bridge.
if (w.gitmost && w.gitmost.insertRecording === insertRecording) {
w.gitmost.ready = false;
delete w.gitmost.insertRecording;
}
};
}, [editor, pageId, editorIsEditable]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);