Files
kintone-customize-manager/src/main/ipc-handlers.ts

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);
});
}