681 lines
18 KiB
TypeScript
681 lines
18 KiB
TypeScript
/**
|
|
* IPC Handlers
|
|
* Bridges renderer requests to main process modules
|
|
* Based on REQUIREMENTS.md:228-268
|
|
*/
|
|
|
|
import { ipcMain, dialog, app } from "electron";
|
|
import { autoUpdater } from "electron-updater";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
saveDomain,
|
|
getDomain,
|
|
deleteDomain,
|
|
listDomains,
|
|
listVersions,
|
|
deleteVersion,
|
|
saveDownload,
|
|
saveBackup,
|
|
getLocale,
|
|
setLocale,
|
|
} from "./storage";
|
|
import { KintoneClient, createKintoneClient } from "./kintone-api";
|
|
import type { Result } from "@shared/types/ipc";
|
|
import type {
|
|
CreateDomainParams,
|
|
UpdateDomainParams,
|
|
TestDomainConnectionParams,
|
|
GetAppsParams,
|
|
GetAppDetailParams,
|
|
GetFileContentParams,
|
|
DeployParams,
|
|
DeployResult,
|
|
DownloadParams,
|
|
DownloadResult,
|
|
GetVersionsParams,
|
|
RollbackParams,
|
|
SetLocaleParams,
|
|
CheckUpdateResult,
|
|
} from "@shared/types/ipc";
|
|
import type { LocaleCode } from "@shared/types/locale";
|
|
import type {
|
|
Domain,
|
|
DomainWithStatus,
|
|
DomainWithPassword,
|
|
} from "@shared/types/domain";
|
|
import type {
|
|
Version,
|
|
DownloadMetadata,
|
|
BackupMetadata,
|
|
DownloadFile,
|
|
} from "@shared/types/version";
|
|
import type { AppCustomizeParameter, AppDetail } from "@shared/types/kintone";
|
|
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
|
import { getErrorMessage } from "./errors";
|
|
|
|
// Cache for Kintone clients
|
|
const clientCache = new Map<string, KintoneClient>();
|
|
|
|
/**
|
|
* Get or create a Kintone client for a domain
|
|
*/
|
|
async function getClient(domainId: string): Promise<KintoneClient> {
|
|
if (clientCache.has(domainId)) {
|
|
return clientCache.get(domainId)!;
|
|
}
|
|
|
|
const domainWithPassword = await getDomain(domainId);
|
|
if (!domainWithPassword) {
|
|
throw new Error(getErrorMessage("domainNotFound"));
|
|
}
|
|
|
|
const client = createKintoneClient(domainWithPassword);
|
|
clientCache.set(domainId, client);
|
|
return client;
|
|
}
|
|
|
|
/**
|
|
* Helper to wrap IPC handlers with error handling
|
|
*/
|
|
function handle<P = void, T = unknown>(
|
|
channel: string,
|
|
handler: (params: P) => Promise<T>,
|
|
): void {
|
|
ipcMain.handle(channel, async (_event, params: P): Promise<Result<T>> => {
|
|
try {
|
|
// For handlers without params (P=void), params will be undefined but handler ignores it
|
|
// For handlers with params, params will be the typed value
|
|
const data = await handler(params as P);
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : getErrorMessage("unknownError");
|
|
return { success: false, error: message };
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== Domain Management IPC Handlers ====================
|
|
|
|
/**
|
|
* Get all domains
|
|
*/
|
|
function registerGetDomains(): void {
|
|
handle("getDomains", async () => {
|
|
return listDomains();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new domain
|
|
* Deduplication: Check if domain+username already exists
|
|
*/
|
|
function registerCreateDomain(): void {
|
|
handle<CreateDomainParams, Domain>("createDomain", async (params) => {
|
|
// Check for duplicate domain+username
|
|
const existingDomains = await listDomains();
|
|
const duplicate = existingDomains.find(
|
|
(d) =>
|
|
d.domain.toLowerCase() === params.domain.toLowerCase() &&
|
|
d.username.toLowerCase() === params.username.toLowerCase(),
|
|
);
|
|
|
|
if (duplicate) {
|
|
throw new Error(
|
|
getErrorMessage("domainDuplicate", {
|
|
domain: params.domain,
|
|
username: params.username,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const domain: Domain = {
|
|
id: uuidv4(),
|
|
name: params.name,
|
|
domain: params.domain,
|
|
username: params.username,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await saveDomain(domain, params.password);
|
|
return domain;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update an existing domain
|
|
*/
|
|
function registerUpdateDomain(): void {
|
|
handle<UpdateDomainParams, Domain>("updateDomain", async (params) => {
|
|
const domains = await listDomains();
|
|
const existing = domains.find((d) => d.id === params.id);
|
|
|
|
if (!existing) {
|
|
throw new Error(getErrorMessage("domainNotFound"));
|
|
}
|
|
|
|
const updated: Domain = {
|
|
...existing,
|
|
name: params.name ?? existing.name,
|
|
domain: params.domain ?? existing.domain,
|
|
username: params.username ?? existing.username,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
// If password is provided, update it
|
|
if (params.password) {
|
|
await saveDomain(updated, params.password);
|
|
} else {
|
|
// Get existing password and re-save
|
|
const existingWithPassword = await getDomain(params.id);
|
|
if (existingWithPassword) {
|
|
await saveDomain(updated, existingWithPassword.password);
|
|
}
|
|
}
|
|
|
|
// Clear cached client
|
|
clientCache.delete(params.id);
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a domain
|
|
*/
|
|
function registerDeleteDomain(): void {
|
|
handle<string, void>("deleteDomain", async (id) => {
|
|
await deleteDomain(id);
|
|
clientCache.delete(id);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test domain connection
|
|
*/
|
|
function registerTestConnection(): void {
|
|
handle<string, DomainWithStatus>("testConnection", async (id) => {
|
|
const domainWithPassword = await getDomain(id);
|
|
if (!domainWithPassword) {
|
|
throw new Error(getErrorMessage("domainNotFound"));
|
|
}
|
|
|
|
const client = createKintoneClient(domainWithPassword);
|
|
const result = await client.testConnection();
|
|
|
|
return {
|
|
...domainWithPassword,
|
|
connectionStatus: result.success ? "connected" : "error",
|
|
connectionError: result.error,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test domain connection with temporary credentials
|
|
*/
|
|
function registerTestDomainConnection(): void {
|
|
handle<TestDomainConnectionParams, boolean>(
|
|
"testDomainConnection",
|
|
async (params) => {
|
|
const tempDomain: DomainWithPassword = {
|
|
id: "temp",
|
|
name: "temp",
|
|
domain: params.domain,
|
|
username: params.username,
|
|
password: params.password || "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const client = createKintoneClient(tempDomain);
|
|
const result = await client.testConnection();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || getErrorMessage("connectionFailed"));
|
|
}
|
|
|
|
return true;
|
|
},
|
|
);
|
|
}
|
|
|
|
// ==================== Browse IPC Handlers ====================
|
|
|
|
/**
|
|
* Get apps
|
|
*/
|
|
function registerGetApps(): void {
|
|
handle<GetAppsParams, Awaited<ReturnType<KintoneClient["getApps"]>>>(
|
|
"getApps",
|
|
async (params) => {
|
|
const client = await getClient(params.domainId);
|
|
return client.getApps({
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get app detail
|
|
*/
|
|
function registerGetAppDetail(): void {
|
|
handle<
|
|
GetAppDetailParams,
|
|
Awaited<ReturnType<KintoneClient["getAppDetail"]>>
|
|
>("getAppDetail", async (params) => {
|
|
const client = await getClient(params.domainId);
|
|
return client.getAppDetail(params.appId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get file content
|
|
*/
|
|
function registerGetFileContent(): void {
|
|
handle<
|
|
GetFileContentParams,
|
|
Awaited<ReturnType<KintoneClient["getFileContent"]>>
|
|
>("getFileContent", async (params) => {
|
|
const client = await getClient(params.domainId);
|
|
return client.getFileContent(params.fileKey);
|
|
});
|
|
}
|
|
|
|
// ==================== Deploy IPC Handlers ====================
|
|
|
|
/**
|
|
* Add files to backup for a specific platform and file type
|
|
*/
|
|
async function addFilesToBackup(
|
|
platform: "desktop" | "mobile",
|
|
fileType: "js" | "css",
|
|
client: KintoneClient,
|
|
appDetail: AppDetail,
|
|
backupFiles: Map<string, Buffer>,
|
|
backupFileList: DownloadFile[]
|
|
): Promise<void> {
|
|
const files = appDetail.customization?.[platform]?.[fileType] || [];
|
|
|
|
for (const [index, file] of files.entries()) {
|
|
const fileKey = getFileKey(file);
|
|
if (fileKey) {
|
|
const fileContent = await client.getFileContent(fileKey);
|
|
const content = Buffer.from(fileContent.content || "", "base64");
|
|
const fileName = getDisplayName(file, fileType, index);
|
|
const type = platform === "desktop" ? "pc" : "mobile";
|
|
|
|
backupFiles.set(`${type}/${fileName}`, content);
|
|
backupFileList.push({
|
|
type,
|
|
fileType,
|
|
fileName,
|
|
fileKey,
|
|
size: content.length,
|
|
path: `${type}/${fileName}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deploy files to Kintone
|
|
*/
|
|
function registerDeploy(): void {
|
|
handle<DeployParams, DeployResult>("deploy", async (params) => {
|
|
const client = await getClient(params.domainId);
|
|
const domainWithPassword = await getDomain(params.domainId);
|
|
|
|
if (!domainWithPassword) {
|
|
throw new Error(getErrorMessage("domainNotFound"));
|
|
}
|
|
|
|
// Get current app config for backup
|
|
const appDetail = await client.getAppDetail(params.appId);
|
|
|
|
// Create backup of current configuration
|
|
const backupFiles = new Map<string, Buffer>();
|
|
const backupFileList: BackupMetadata["files"] = [];
|
|
|
|
// Add desktop files to backup
|
|
await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
|
|
await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
|
|
|
|
// Add mobile files to backup
|
|
await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
|
|
await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
|
|
|
|
// Save backup
|
|
const backupMetadata: BackupMetadata = {
|
|
backedUpAt: new Date().toISOString(),
|
|
domain: domainWithPassword.domain,
|
|
domainId: params.domainId,
|
|
appId: params.appId,
|
|
appName: appDetail.name,
|
|
files: backupFileList,
|
|
trigger: "deploy",
|
|
};
|
|
|
|
const backupPath = await saveBackup(backupMetadata, backupFiles);
|
|
|
|
// Upload new files and build customization config directly
|
|
const newConfig: AppCustomizeParameter = {
|
|
app: params.appId,
|
|
scope: "ALL",
|
|
desktop: {
|
|
js: [],
|
|
css: [],
|
|
},
|
|
mobile: {
|
|
js: [],
|
|
css: [],
|
|
},
|
|
};
|
|
|
|
for (const file of params.files) {
|
|
const fileKey = await client.uploadFile(file.content, file.fileName);
|
|
const fileEntry = { type: "FILE" as const, file: { fileKey: fileKey.fileKey } };
|
|
|
|
// Add to corresponding field based on file type and position
|
|
if (file.fileType === "js") {
|
|
if (file.position.startsWith("pc_")) {
|
|
newConfig.desktop!.js!.push(fileEntry);
|
|
} else if (file.position.startsWith("mobile_")) {
|
|
newConfig.mobile!.js!.push(fileEntry);
|
|
}
|
|
} else if (file.fileType === "css") {
|
|
if (file.position === "pc_css") {
|
|
newConfig.desktop!.css!.push(fileEntry);
|
|
} else if (file.position === "mobile_css") {
|
|
newConfig.mobile!.css!.push(fileEntry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update app customization
|
|
await client.updateAppCustomize(params.appId, newConfig);
|
|
|
|
// Deploy the changes
|
|
await client.deployApp(params.appId);
|
|
|
|
return {
|
|
success: true,
|
|
backupPath,
|
|
backupMetadata,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ==================== Download IPC Handlers ====================
|
|
|
|
/**
|
|
* Download files from Kintone
|
|
*/
|
|
function registerDownload(): void {
|
|
handle<DownloadParams, DownloadResult>("download", async (params) => {
|
|
const client = await getClient(params.domainId);
|
|
const domainWithPassword = await getDomain(params.domainId);
|
|
|
|
if (!domainWithPassword) {
|
|
throw new Error(getErrorMessage("domainNotFound"));
|
|
}
|
|
|
|
const appDetail = await client.getAppDetail(params.appId);
|
|
const downloadFiles = new Map<string, Buffer>();
|
|
const downloadFileList: DownloadMetadata["files"] = [];
|
|
|
|
// Download based on requested file types
|
|
const fileTypes = params.fileTypes || [
|
|
"pc_js",
|
|
"pc_css",
|
|
"mobile_js",
|
|
"mobile_css",
|
|
];
|
|
|
|
for (const fileType of fileTypes) {
|
|
const files =
|
|
fileType === "pc_js"
|
|
? appDetail.customization?.desktop?.js
|
|
: fileType === "pc_css"
|
|
? appDetail.customization?.desktop?.css
|
|
: fileType === "mobile_js"
|
|
? appDetail.customization?.mobile?.js
|
|
: appDetail.customization?.mobile?.css;
|
|
|
|
for (const [index, file] of (files || []).entries()) {
|
|
const fileKey = getFileKey(file);
|
|
if (fileKey) {
|
|
const content = await client.getFileContent(fileKey);
|
|
const buffer = Buffer.from(content.content || "", "base64");
|
|
const ext = fileType.includes("js") ? "js" : "css";
|
|
const fileName = getDisplayName(file, ext, index);
|
|
const type = fileType.includes("mobile") ? "mobile" : "pc";
|
|
|
|
downloadFiles.set(`${type}/${fileName}`, buffer);
|
|
downloadFileList.push({
|
|
type,
|
|
fileType: ext,
|
|
fileName,
|
|
fileKey,
|
|
size: buffer.length,
|
|
path: `${type}/${fileName}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save download metadata
|
|
const metadata: DownloadMetadata = {
|
|
downloadedAt: new Date().toISOString(),
|
|
domain: domainWithPassword.domain,
|
|
domainId: params.domainId,
|
|
appId: params.appId,
|
|
appName: appDetail.name,
|
|
spaceId: appDetail.spaceId || "",
|
|
spaceName: "", // Would need to fetch from space API
|
|
files: downloadFileList,
|
|
source: "kintone",
|
|
};
|
|
|
|
const path = await saveDownload(metadata, downloadFiles);
|
|
|
|
return {
|
|
success: true,
|
|
path,
|
|
metadata,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ==================== Version IPC Handlers ====================
|
|
|
|
/**
|
|
* Get versions for an app
|
|
*/
|
|
function registerGetVersions(): void {
|
|
handle<GetVersionsParams, Version[]>("getVersions", async (params) => {
|
|
return listVersions(params.domainId, params.appId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a version
|
|
*/
|
|
function registerDeleteVersion(): void {
|
|
handle<string, void>("deleteVersion", async (id) => {
|
|
return deleteVersion(id);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rollback to a previous version
|
|
*/
|
|
function registerRollback(): void {
|
|
handle<RollbackParams, DeployResult>("rollback", async (_params) => {
|
|
// This would read the version file and redeploy
|
|
// Simplified implementation - would need full implementation
|
|
throw new Error(getErrorMessage("rollbackNotImplemented"));
|
|
});
|
|
}
|
|
|
|
// ==================== Locale IPC Handlers ====================
|
|
|
|
/**
|
|
* Get the current locale
|
|
*/
|
|
function registerGetLocale(): void {
|
|
handle<void, LocaleCode>("getLocale", async () => {
|
|
return getLocale();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set the locale
|
|
*/
|
|
function registerSetLocale(): void {
|
|
handle<SetLocaleParams, void>("setLocale", async (params) => {
|
|
setLocale(params.locale);
|
|
});
|
|
}
|
|
|
|
// ==================== App Version & Update IPC Handlers ====================
|
|
|
|
/**
|
|
* Get the current app version
|
|
*/
|
|
function registerGetAppVersion(): void {
|
|
handle<void, string>("getAppVersion", async () => {
|
|
return app.getVersion();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check for app updates
|
|
*/
|
|
function registerCheckForUpdates(): void {
|
|
handle<void, CheckUpdateResult>("checkForUpdates", async () => {
|
|
try {
|
|
// In development, autoUpdater.checkForUpdates will throw an error
|
|
// because there's no update server configured
|
|
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
|
// Return mock result for development
|
|
return {
|
|
hasUpdate: false,
|
|
updateInfo: undefined,
|
|
};
|
|
}
|
|
|
|
const result = await autoUpdater.checkForUpdates();
|
|
|
|
if (result && result.updateInfo) {
|
|
const currentVersion = app.getVersion();
|
|
const latestVersion = result.updateInfo.version;
|
|
|
|
// Compare versions
|
|
const hasUpdate = latestVersion !== currentVersion;
|
|
|
|
return {
|
|
hasUpdate,
|
|
updateInfo: hasUpdate
|
|
? {
|
|
version: result.updateInfo.version,
|
|
releaseDate: result.updateInfo.releaseDate,
|
|
releaseNotes: result.releaseNotes as string | undefined,
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
return {
|
|
hasUpdate: false,
|
|
updateInfo: undefined,
|
|
};
|
|
} catch (error) {
|
|
// If update check fails, return no update available
|
|
console.error("Update check failed:", error);
|
|
return {
|
|
hasUpdate: false,
|
|
updateInfo: undefined,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== Register All Handlers ====================
|
|
|
|
/**
|
|
* Register all IPC handlers
|
|
*/
|
|
export function registerIpcHandlers(): void {
|
|
// Domain management
|
|
registerGetDomains();
|
|
registerCreateDomain();
|
|
registerUpdateDomain();
|
|
registerDeleteDomain();
|
|
registerTestConnection();
|
|
registerTestDomainConnection();
|
|
|
|
// Browse
|
|
registerGetApps();
|
|
registerGetAppDetail();
|
|
registerGetFileContent();
|
|
|
|
// Deploy
|
|
registerDeploy();
|
|
|
|
// Download
|
|
registerDownload();
|
|
|
|
// Version
|
|
registerGetVersions();
|
|
registerDeleteVersion();
|
|
registerRollback();
|
|
|
|
// Locale
|
|
registerGetLocale();
|
|
registerSetLocale();
|
|
|
|
// App Version & Update
|
|
registerGetAppVersion();
|
|
registerCheckForUpdates();
|
|
|
|
// Dialog
|
|
registerShowSaveDialog();
|
|
registerSaveFileContent();
|
|
|
|
console.log("IPC handlers registered");
|
|
}
|
|
|
|
// ==================== Dialog IPC Handlers ====================
|
|
|
|
/**
|
|
* Show save dialog
|
|
*/
|
|
function registerShowSaveDialog(): void {
|
|
handle<{ defaultPath?: string }, string | null>("showSaveDialog", async (params) => {
|
|
const result = await dialog.showSaveDialog({
|
|
defaultPath: params.defaultPath,
|
|
filters: [
|
|
{ name: "JavaScript", extensions: ["js"] },
|
|
{ name: "CSS", extensions: ["css"] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
});
|
|
return result.filePath || null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save file content to disk
|
|
*/
|
|
function registerSaveFileContent(): void {
|
|
handle<{ filePath: string; content: string }, void>("saveFileContent", async (params) => {
|
|
const fs = await import("fs");
|
|
const buffer = Buffer.from(params.content, "base64");
|
|
await fs.promises.writeFile(params.filePath, buffer);
|
|
});
|
|
}
|