feat(原生桌面): 新增原生桌面与字体适配
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-10-17 17:28:36 +08:00
1 parent 0ab785ebc3
commit 8e31f888d3
19 files changed
+9092 -6499

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
export function createEngineWorker() {
return nativeHost ? nativeHost.worker() : new Worker("./ofdgo_wasm.js");
}
export async function documentBytes(file) {
return file?.nativeFile ? file : new Uint8Array(await file.arrayBuffer());
}
export function saveFilePicker(options) {
return nativeHost ? nativeHost.savePicker(options) : window.showSaveFilePicker ? window.showSaveFilePicker(options) : null;
}
export function directoryPicker() {
return nativeHost ? nativeHost.directoryPicker() : window.showDirectoryPicker({mode:"readwrite"});
}
class NativeFile {
constructor(value) {
Object.assign(this, value);
this.byteLength = this.size;
this.type ||= "application/octet-stream";
if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile);
}
async arrayBuffer() {
const response = await fetch(this.url);
if (!response.ok) throw new Error("读取文件失败");
return response.arrayBuffer();
}
async text() {
return new TextDecoder().decode(await this.arrayBuffer());
}
}
class NativeHandle {
constructor(host, value) {
this.host = host;
Object.assign(this, value);
}
async getFile() {
return new NativeFile(await this.host.call("file", {handle:this.nativeHandle}));
}
async getFileHandle(name, options = {}) {
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:false}));
}
async getDirectoryHandle(name, options = {}) {
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:true}));
}
removeEntry(name, options = {}) {
return this.host.call("remove", {handle:this.nativeHandle, name, recursive:!!options.recursive});
}
async createWritable() {
const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle});
return {
write: async data => this.host.call("write.data", {writer, data:await this.host.encode(data)}),
close: () => this.host.call("write.commit", {writer}),
abort: () => this.host.call("write.abort", {writer}),
};
}
}
class NativeHost {
constructor(bridge) {
this.bridge = bridge;
this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => {
this.call("release", {file}).catch(() => {});
}) : null;
}
call(name, value) {
return this.bridge.call(`ofdgo.host.${name}`, value);
}
async encode(value, key = "") {
if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name};
if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind};
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
if (!(value instanceof Blob)) {
const bytes = ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value);
if (bytes.byteLength <= 65536 || ["password", "key", "keyPassword", "certificate"].includes(key)) return {nativeBytes:Array.from(bytes)};
}
const response = await fetch("/__native/input", {method:"POST", body:value});
if (!response.ok) throw new Error("传递数据失败");
return response.json();
}
if (Array.isArray(value)) return Promise.all(value.map(item => this.encode(item)));
if (value && typeof value === "object") {
const result = {};
for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key);
return result;
}
return value;
}
async decode(value, name = "", key = "") {
if (value?.nativeFile) {
const file = new NativeFile(value);
if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file;
try {
const bytes = await file.arrayBuffer();
return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes);
} finally {
await this.call("release", {file:value.nativeFile});
}
}
if (Array.isArray(value)) return Promise.all(value.map(item => this.decode(item, name)));
if (value && typeof value === "object") {
const result = {};
for (const [field, item] of Object.entries(value)) result[field] = await this.decode(item, name, field);
return result;
}
return value;
}
worker() {
const host = this;
let closed = false;
let unsubscribe;
let pending = Promise.resolve();
const canceled = new Set();
const emit = data => { if (!closed) worker.onmessage?.({data}); };
const ready = host.call("engine.open", {}).then(({engine}) => {
if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; }
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
emit({type:"ready"});
return engine;
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
const worker = {
onmessage:null,
onerror:null,
postMessage(data) {
if (closed) return;
if (data.type === "cancel") {
canceled.add(data.id);
ready.then(engine => host.call("engine.cancel", {engine, id:data.id})).catch(() => {});
return;
}
if (data.type === "font") return;
pending = pending.then(async () => {
const engine = await ready;
if (closed) return;
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
const args = await host.encode(data.args);
if (closed) return;
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args});
emit({id:data.id, ...await host.decode(result, data.name)});
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id));
},
terminate() {
if (closed) return;
closed = true;
unsubscribe?.();
ready.then(engine => host.call("engine.close", {engine})).catch(() => {});
},
};
ready.catch(() => {});
return worker;
}
async savePicker(options) {
return new NativeHandle(this, await this.call("save", options));
}
async directoryPicker() {
return new NativeHandle(this, await this.call("directory", {}));
}
async fonts() {
return (await this.call("fonts", {})).map(font => ({...font, nativeFile:new NativeFile(font.file)}));
}
async start(onFiles, onError) {
this.bridge.on("ofdgo.files", values => onFiles(values.map(value => new NativeFile(value))));
this.bridge.on("ofdgo.error", message => onError(new Error(message)));
const title = document.querySelector("title");
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
await this.call("ready", {});
}
installFileInputs(onError) {
document.addEventListener("click", async event => {
const input = event.target;
if (!(input instanceof HTMLInputElement) || input.type !== "file" || input.disabled) return;
event.preventDefault();
try {
const files = (await this.call("open", {multiple:input.multiple, directory:input.webkitdirectory, accept:input.accept})).map(value => new NativeFile(value));
if (!files.length) return;
const transfer = new DataTransfer();
for (const file of files) transfer.items.add(new File([], file.name, {type:file.type}));
delete input.files;
input.files = transfer.files;
Object.defineProperty(input, "files", {configurable:true, get:() => input.value ? files : []});
input.dispatchEvent(new Event("input", {bubbles:true}));
input.dispatchEvent(new Event("change", {bubbles:true}));
} catch (error) { if (error.name !== "AbortError") onError(error); }
}, true);
}
async download(data, mime, name) {
const handle = await this.savePicker({suggestedName:name});
const writer = await handle.createWritable();
try { await writer.write(data); await writer.close(); }
catch (error) { await writer.abort().catch(() => {}); throw error; }
}
}
export const nativeHost = globalThis.guigo ? new NativeHost(globalThis.guigo) : null;