389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
import {
|
|
app,
|
|
Menu,
|
|
net,
|
|
protocol,
|
|
session
|
|
} from "electron";
|
|
import { access, mkdir } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
createApplicationService,
|
|
type ApplicationService
|
|
} from "@md-to-pdf/application";
|
|
import {
|
|
parseFontPackResourceUrl,
|
|
parseThemeResourceUrl
|
|
} from "./application-contract.js";
|
|
import {
|
|
DesktopApplicationController
|
|
} from "./desktop-application-controller.js";
|
|
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
|
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
|
import {
|
|
findMarkdownFileArgument,
|
|
isMarkdownFilePath
|
|
} from "./markdown-file.js";
|
|
import { migrateLegacyUserData } from "./user-data-migration.js";
|
|
|
|
const APP_SCHEME = "mdpdf";
|
|
const APP_HOST = "bundle";
|
|
const THEME_HOST = "theme";
|
|
const FONT_PACK_HOST = "font-pack";
|
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
let applicationController: DesktopApplicationController | undefined;
|
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
|
process.argv,
|
|
process.cwd()
|
|
);
|
|
|
|
function configurePackagedUserDataDirectory() {
|
|
if (!app.isPackaged) {
|
|
return Promise.resolve();
|
|
}
|
|
const appDataDirectory = app.getPath("appData");
|
|
const targetDirectory = path.join(
|
|
appDataDirectory,
|
|
__APP_ENGLISH_NAME__
|
|
);
|
|
const targetAlreadyExists = existsSync(targetDirectory);
|
|
app.setPath("userData", targetDirectory);
|
|
if (targetAlreadyExists) {
|
|
return Promise.resolve();
|
|
}
|
|
return migrateLegacyUserData(
|
|
[path.join(appDataDirectory, __APP_LEGACY_DISPLAY_NAME__)],
|
|
targetDirectory
|
|
).then(() => undefined);
|
|
}
|
|
|
|
const userDataMigration = configurePackagedUserDataDirectory();
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
{
|
|
scheme: APP_SCHEME,
|
|
privileges: {
|
|
standard: true,
|
|
secure: true,
|
|
supportFetchAPI: true,
|
|
stream: true,
|
|
codeCache: true,
|
|
corsEnabled: true
|
|
}
|
|
}
|
|
]);
|
|
|
|
function getCommandLineValue(name: string) {
|
|
const prefix = `--${name}=`;
|
|
return process.argv
|
|
.find((argument) => argument.startsWith(prefix))
|
|
?.slice(prefix.length);
|
|
}
|
|
|
|
function getWebRoot() {
|
|
return app.isPackaged
|
|
? path.join(process.resourcesPath, "dist")
|
|
: path.resolve(currentDirectory, "../../web/dist");
|
|
}
|
|
|
|
function getApplicationIcon() {
|
|
if (app.isPackaged) {
|
|
return path.join(
|
|
process.resourcesPath,
|
|
process.platform === "darwin" ? "app.icns" : "app.ico"
|
|
);
|
|
}
|
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
|
return path.join(
|
|
projectRoot,
|
|
"logos",
|
|
"desktop",
|
|
process.platform === "darwin" ? "macos" : "windows",
|
|
process.platform === "darwin" ? "app.icns" : "app.ico"
|
|
);
|
|
}
|
|
|
|
function encodeThemeAssetPath(assetPath: string) {
|
|
return assetPath
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
}
|
|
|
|
function dispatchExternalMarkdown(filePath: string) {
|
|
if (!applicationController) {
|
|
pendingExternalMarkdownPath = filePath;
|
|
return;
|
|
}
|
|
void applicationController.openExternalMarkdown(filePath).catch(
|
|
(error: unknown) => {
|
|
console.warn("无法打开系统传入的 Markdown 文件", error);
|
|
}
|
|
);
|
|
}
|
|
|
|
function getDesktopThemeDirectory() {
|
|
return app.isPackaged
|
|
? path.join(app.getPath("userData"), "themes")
|
|
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
|
}
|
|
|
|
function getDesktopFontPackDirectory() {
|
|
if (!app.isPackaged) {
|
|
return path.resolve(
|
|
currentDirectory,
|
|
"../../..",
|
|
".local",
|
|
"font-packs"
|
|
);
|
|
}
|
|
const localApplicationData = process.env.LOCALAPPDATA?.trim();
|
|
return localApplicationData
|
|
? path.join(localApplicationData, "md-to-pdf", "font-packs")
|
|
: path.join(app.getPath("userData"), "font-packs");
|
|
}
|
|
|
|
function createDesktopApplicationService() {
|
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
|
return createApplicationService({
|
|
bundledRoot: app.isPackaged
|
|
? path.join(process.resourcesPath, "themes")
|
|
: path.join(projectRoot, "themes"),
|
|
localRoot: getDesktopThemeDirectory(),
|
|
createAssetUrl: (themeId, assetPath) =>
|
|
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
|
fontPacks: {
|
|
roots: [getDesktopFontPackDirectory()],
|
|
appVersion: app.getVersion(),
|
|
createAssetUrl: (
|
|
packId,
|
|
version,
|
|
faceId,
|
|
kind,
|
|
sha256
|
|
) =>
|
|
`${APP_SCHEME}://${FONT_PACK_HOST}/${encodeURIComponent(
|
|
packId
|
|
)}/${encodeURIComponent(version)}/${encodeURIComponent(
|
|
faceId
|
|
)}/${kind}?v=${sha256}`
|
|
},
|
|
onWarning: (message) => console.warn(message)
|
|
});
|
|
}
|
|
|
|
function isContainedPath(root: string, target: string) {
|
|
const relative = path.relative(root, target);
|
|
return (
|
|
relative !== ".." &&
|
|
!relative.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relative)
|
|
);
|
|
}
|
|
|
|
async function registerApplicationProtocol(
|
|
applicationService: ApplicationService
|
|
) {
|
|
const webRoot = getWebRoot();
|
|
const handleRequest = async (request: Request) => {
|
|
const url = new URL(request.url);
|
|
if (url.host === THEME_HOST) {
|
|
try {
|
|
const resource = parseThemeResourceUrl(request.url);
|
|
if (!resource) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
const asset = await applicationService.getThemeAsset(
|
|
resource.themeId,
|
|
resource.assetPath
|
|
);
|
|
if (!asset) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
return new Response(asset.content, {
|
|
headers: {
|
|
"access-control-allow-origin": "*",
|
|
"cache-control": "public, max-age=300",
|
|
"content-type": asset.contentType,
|
|
"x-content-type-options": "nosniff"
|
|
}
|
|
});
|
|
} catch {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
}
|
|
if (url.host === FONT_PACK_HOST) {
|
|
try {
|
|
const resource = parseFontPackResourceUrl(request.url);
|
|
if (!resource) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
const asset = await applicationService.getFontPackAsset(
|
|
resource.packId,
|
|
resource.packVersion,
|
|
resource.faceId,
|
|
resource.kind
|
|
);
|
|
if (!asset) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
return new Response(asset.content, {
|
|
headers: {
|
|
"access-control-allow-origin": "*",
|
|
"cache-control": "public, max-age=31536000, immutable",
|
|
"content-type": asset.contentType,
|
|
etag: `\"${asset.sha256}\"`,
|
|
"x-content-type-options": "nosniff"
|
|
}
|
|
});
|
|
} catch {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
}
|
|
if (url.host !== APP_HOST) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
|
|
let relativePath: string;
|
|
try {
|
|
relativePath =
|
|
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
|
|
"index.html";
|
|
} catch {
|
|
return new Response("Bad request", { status: 400 });
|
|
}
|
|
const target = path.resolve(webRoot, relativePath);
|
|
if (!isContainedPath(webRoot, target)) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
try {
|
|
await access(target);
|
|
} catch {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
return net.fetch(pathToFileURL(target).href);
|
|
};
|
|
|
|
await Promise.all([
|
|
protocol.handle(APP_SCHEME, handleRequest),
|
|
session
|
|
.fromPartition(DESKTOP_PDF_PARTITION)
|
|
.protocol.handle(APP_SCHEME, handleRequest),
|
|
session
|
|
.fromPartition(DESKTOP_DOCX_PARTITION)
|
|
.protocol.handle(APP_SCHEME, handleRequest)
|
|
]);
|
|
}
|
|
|
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
|
let windowStateReadyToQuit = false;
|
|
|
|
if (!hasSingleInstanceLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
|
const filePath = findMarkdownFileArgument(
|
|
arguments_,
|
|
workingDirectory
|
|
);
|
|
if (filePath) {
|
|
dispatchExternalMarkdown(filePath);
|
|
} else if (!applicationController?.focusMostRecentWindow()) {
|
|
void applicationController?.createWindow();
|
|
}
|
|
});
|
|
app.on("open-file", (event, filePath) => {
|
|
event.preventDefault();
|
|
if (isMarkdownFilePath(filePath)) {
|
|
dispatchExternalMarkdown(filePath);
|
|
}
|
|
});
|
|
app.on("activate", () => {
|
|
if (!applicationController?.focusMostRecentWindow()) {
|
|
void applicationController?.createWindow();
|
|
}
|
|
});
|
|
app.on("before-quit", (event) => {
|
|
if (windowStateReadyToQuit || !applicationController) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
void Promise.all([
|
|
applicationController.flushWindowState(),
|
|
applicationController.closeResources()
|
|
]).finally(() => {
|
|
windowStateReadyToQuit = true;
|
|
app.quit();
|
|
setTimeout(() => {
|
|
windowStateReadyToQuit = false;
|
|
}, 1_000);
|
|
});
|
|
});
|
|
app
|
|
.whenReady()
|
|
.then(async () => {
|
|
await userDataMigration;
|
|
Menu.setApplicationMenu(null);
|
|
if (process.platform === "win32") {
|
|
app.setAppUserModelId(__APP_ID__);
|
|
}
|
|
const themeDirectory = getDesktopThemeDirectory();
|
|
await mkdir(themeDirectory, { recursive: true });
|
|
const applicationService = createDesktopApplicationService();
|
|
await registerApplicationProtocol(applicationService);
|
|
|
|
const developmentUrl = getCommandLineValue("web-url");
|
|
const applicationUrl =
|
|
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
|
applicationController = new DesktopApplicationController({
|
|
applicationService,
|
|
applicationUrl,
|
|
renderUrl: new URL(
|
|
"/preview-frame.html?target=pdf",
|
|
applicationUrl
|
|
).href,
|
|
preloadPath: path.join(
|
|
currentDirectory,
|
|
"app-preload.cjs"
|
|
),
|
|
pdfPreloadPath: path.join(
|
|
currentDirectory,
|
|
"pdf-preload.cjs"
|
|
),
|
|
docxRenderUrl: new URL(
|
|
"/preview-frame.html?target=continuous",
|
|
applicationUrl
|
|
).href,
|
|
desktopResourcesPath: process.resourcesPath,
|
|
iconPath: getApplicationIcon(),
|
|
themeDirectory,
|
|
windowStatePath: path.join(
|
|
app.getPath("userData"),
|
|
"window-state.json"
|
|
),
|
|
saveLocationStatePath: path.join(
|
|
app.getPath("userData"),
|
|
"save-location.json"
|
|
),
|
|
documentsDirectory: app.getPath("documents")
|
|
});
|
|
await applicationController.initialize();
|
|
|
|
const startupMarkdownPath = pendingExternalMarkdownPath;
|
|
pendingExternalMarkdownPath = undefined;
|
|
await applicationController.createWindow(startupMarkdownPath);
|
|
})
|
|
.catch((error: unknown) => {
|
|
console.error("桌面应用启动失败", error);
|
|
app.quit();
|
|
});
|
|
}
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|