This commit is contained in:
2026-03-13 15:25:44 +08:00
parent 4ec09661cd
commit 8ff555f9e4
11 changed files with 393 additions and 371 deletions

View File

@@ -4,15 +4,28 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
main: { main: {
resolve: {
alias: {
'@shared': resolve('src/shared'),
'@main': resolve('src/main')
}
},
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
preload: { preload: {
resolve: {
alias: {
'@shared': resolve('src/shared'),
'@preload': resolve('src/preload')
}
},
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
renderer: { renderer: {
resolve: { resolve: {
alias: { alias: {
'@renderer': resolve('src/renderer/src') '@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
} }
}, },
plugins: [react()] plugins: [react()]

View File

@@ -9,7 +9,7 @@
*/ */
import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { SelfKintoneClient, createKintoneClient } from "@main/kintone-api"; import { KintoneClient, createKintoneClient } from "@main/kintone-api";
import type { DomainWithPassword } from "@shared/types/domain"; import type { DomainWithPassword } from "@shared/types/domain";
// Test configuration // Test configuration
@@ -25,7 +25,7 @@ const TEST_CONFIG: DomainWithPassword = {
}; };
describe("SelfKintoneClient - API Integration Tests", () => { describe("SelfKintoneClient - API Integration Tests", () => {
let client: SelfKintoneClient; let client: KintoneClient;
beforeAll(() => { beforeAll(() => {
// Create client with test credentials // Create client with test credentials
@@ -137,7 +137,7 @@ describe("SelfKintoneClient - API Integration Tests", () => {
describe("Create Kintone Client Factory", () => { describe("Create Kintone Client Factory", () => {
it("should create a client instance", () => { it("should create a client instance", () => {
const client = createKintoneClient(TEST_CONFIG); const client = createKintoneClient(TEST_CONFIG);
expect(client).toBeInstanceOf(SelfKintoneClient); expect(client).toBeInstanceOf(KintoneClient);
}); });
it("should return the correct domain", () => { it("should return the correct domain", () => {

View File

@@ -16,7 +16,7 @@ import {
saveDownload, saveDownload,
saveBackup, saveBackup,
} from "./storage"; } from "./storage";
import { SelfKintoneClient, createKintoneClient } from "./kintone-api"; import { KintoneClient, createKintoneClient } from "./kintone-api";
import type { Result } from "@shared/types/ipc"; import type { Result } from "@shared/types/ipc";
import type { import type {
CreateDomainParams, CreateDomainParams,
@@ -32,21 +32,27 @@ import type {
GetVersionsParams, GetVersionsParams,
RollbackParams, RollbackParams,
} from "@shared/types/ipc"; } from "@shared/types/ipc";
import type { Domain, DomainWithStatus, DomainWithPassword } from "@shared/types/domain"; import type {
Domain,
DomainWithStatus,
DomainWithPassword,
} from "@shared/types/domain";
import type { import type {
Version, Version,
DownloadMetadata, DownloadMetadata,
BackupMetadata, BackupMetadata,
DownloadFile,
} from "@shared/types/version"; } from "@shared/types/version";
import type { AppCustomizeResponse } from "@shared/types/kintone"; import type { AppCustomizeParameter, AppDetail } from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
// Cache for Kintone clients // Cache for Kintone clients
const clientCache = new Map<string, SelfKintoneClient>(); const clientCache = new Map<string, KintoneClient>();
/** /**
* Get or create a Kintone client for a domain * Get or create a Kintone client for a domain
*/ */
async function getClient(domainId: string): Promise<SelfKintoneClient> { async function getClient(domainId: string): Promise<KintoneClient> {
if (clientCache.has(domainId)) { if (clientCache.has(domainId)) {
return clientCache.get(domainId)!; return clientCache.get(domainId)!;
} }
@@ -64,28 +70,15 @@ async function getClient(domainId: string): Promise<SelfKintoneClient> {
/** /**
* Helper to wrap IPC handlers with error handling * Helper to wrap IPC handlers with error handling
*/ */
function handle<T>(channel: string, handler: () => Promise<T>): void { function handle<P = void, T = unknown>(
ipcMain.handle(channel, async (): Promise<Result<T>> => {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message };
}
});
}
/**
* Helper to wrap IPC handlers with parameters
*/
function handleWithParams<P, T>(
channel: string, channel: string,
handler: (params: P) => Promise<T>, handler: (params: P) => Promise<T>,
): void { ): void {
ipcMain.handle(channel, async (_event, params: P): Promise<Result<T>> => { ipcMain.handle(channel, async (_event, params: P): Promise<Result<T>> => {
try { try {
const data = await handler(params); // 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 }; return { success: true, data };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
@@ -110,20 +103,18 @@ function registerGetDomains(): void {
* Deduplication: Check if domain+username already exists * Deduplication: Check if domain+username already exists
*/ */
function registerCreateDomain(): void { function registerCreateDomain(): void {
handleWithParams<CreateDomainParams, Domain>( handle<CreateDomainParams, Domain>("createDomain", async (params) => {
"createDomain",
async (params) => {
// Check for duplicate domain+username // Check for duplicate domain+username
const existingDomains = await listDomains(); const existingDomains = await listDomains();
const duplicate = existingDomains.find( const duplicate = existingDomains.find(
(d) => (d) =>
d.domain.toLowerCase() === params.domain.toLowerCase() && d.domain.toLowerCase() === params.domain.toLowerCase() &&
d.username.toLowerCase() === params.username.toLowerCase() d.username.toLowerCase() === params.username.toLowerCase(),
); );
if (duplicate) { if (duplicate) {
throw new Error( throw new Error(
`Domain "${params.domain}" with user "${params.username}" already exists. Please edit the existing domain instead.` `Domain "${params.domain}" with user "${params.username}" already exists. Please edit the existing domain instead.`,
); );
} }
@@ -141,17 +132,14 @@ function registerCreateDomain(): void {
await saveDomain(domain, params.password); await saveDomain(domain, params.password);
return domain; return domain;
}, });
);
} }
/** /**
* Update an existing domain * Update an existing domain
*/ */
function registerUpdateDomain(): void { function registerUpdateDomain(): void {
handleWithParams<UpdateDomainParams, Domain>( handle<UpdateDomainParams, Domain>("updateDomain", async (params) => {
"updateDomain",
async (params) => {
const domains = await listDomains(); const domains = await listDomains();
const existing = domains.find((d) => d.id === params.id); const existing = domains.find((d) => d.id === params.id);
@@ -184,15 +172,14 @@ function registerUpdateDomain(): void {
clientCache.delete(params.id); clientCache.delete(params.id);
return updated; return updated;
}, });
);
} }
/** /**
* Delete a domain * Delete a domain
*/ */
function registerDeleteDomain(): void { function registerDeleteDomain(): void {
handleWithParams<string, void>("deleteDomain", async (id) => { handle<string, void>("deleteDomain", async (id) => {
await deleteDomain(id); await deleteDomain(id);
clientCache.delete(id); clientCache.delete(id);
}); });
@@ -202,7 +189,7 @@ function registerDeleteDomain(): void {
* Test domain connection * Test domain connection
*/ */
function registerTestConnection(): void { function registerTestConnection(): void {
handleWithParams<string, DomainWithStatus>("testConnection", async (id) => { handle<string, DomainWithStatus>("testConnection", async (id) => {
const domainWithPassword = await getDomain(id); const domainWithPassword = await getDomain(id);
if (!domainWithPassword) { if (!domainWithPassword) {
throw new Error(`Domain not found: ${id}`); throw new Error(`Domain not found: ${id}`);
@@ -223,7 +210,7 @@ function registerTestConnection(): void {
* Test domain connection with temporary credentials * Test domain connection with temporary credentials
*/ */
function registerTestDomainConnection(): void { function registerTestDomainConnection(): void {
handleWithParams<TestDomainConnectionParams, boolean>( handle<TestDomainConnectionParams, boolean>(
"testDomainConnection", "testDomainConnection",
async (params) => { async (params) => {
const tempDomain: DomainWithPassword = { const tempDomain: DomainWithPassword = {
@@ -256,27 +243,25 @@ function registerTestDomainConnection(): void {
* Get apps * Get apps
*/ */
function registerGetApps(): void { function registerGetApps(): void {
handleWithParams< handle<GetAppsParams, Awaited<ReturnType<KintoneClient["getApps"]>>>(
GetAppsParams, "getApps",
Awaited<ReturnType<SelfKintoneClient["getApps"]>> async (params) => {
>("getApps", async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
return client.getApps({ return client.getApps({
limit: params.limit, limit: params.limit,
offset: params.offset, offset: params.offset,
}); });
}); },
);
} }
/** /**
* Get app detail * Get app detail
*/ */
function registerGetAppDetail(): void { function registerGetAppDetail(): void {
handleWithParams< handle<
GetAppDetailParams, GetAppDetailParams,
Awaited<ReturnType<SelfKintoneClient["getAppDetail"]>> Awaited<ReturnType<KintoneClient["getAppDetail"]>>
>("getAppDetail", async (params) => { >("getAppDetail", async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
return client.getAppDetail(params.appId); return client.getAppDetail(params.appId);
@@ -287,9 +272,9 @@ function registerGetAppDetail(): void {
* Get file content * Get file content
*/ */
function registerGetFileContent(): void { function registerGetFileContent(): void {
handleWithParams< handle<
GetFileContentParams, GetFileContentParams,
Awaited<ReturnType<SelfKintoneClient["getFileContent"]>> Awaited<ReturnType<KintoneClient["getFileContent"]>>
>("getFileContent", async (params) => { >("getFileContent", async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
return client.getFileContent(params.fileKey); return client.getFileContent(params.fileKey);
@@ -298,11 +283,45 @@ function registerGetFileContent(): void {
// ==================== Deploy IPC Handlers ==================== // ==================== 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 * Deploy files to Kintone
*/ */
function registerDeploy(): void { function registerDeploy(): void {
handleWithParams<DeployParams, DeployResult>("deploy", async (params) => { handle<DeployParams, DeployResult>("deploy", async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
const domainWithPassword = await getDomain(params.domainId); const domainWithPassword = await getDomain(params.domainId);
@@ -317,39 +336,14 @@ function registerDeploy(): void {
const backupFiles = new Map<string, Buffer>(); const backupFiles = new Map<string, Buffer>();
const backupFileList: BackupMetadata["files"] = []; const backupFileList: BackupMetadata["files"] = [];
// Add JS files to backup // Add desktop files to backup
for (const js of appDetail.customization?.desktop?.js || []) { await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
if (js.file?.fileKey) { await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
const fileContent = await client.getFileContent(js.file.fileKey);
const content = Buffer.from(fileContent.content || "", "base64"); // Add mobile files to backup
backupFiles.set(`pc/${js.file.name || js.file.fileKey}.js`, content); await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
backupFileList.push({ await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
type: "pc",
fileType: "js",
fileName: js.file.name || js.file.fileKey,
fileKey: js.file.fileKey,
size: content.length,
path: `pc/${js.file.name || js.file.fileKey}.js`,
});
}
}
// Add CSS files to backup
for (const css of appDetail.customization?.desktop?.css || []) {
if (css.file?.fileKey) {
const fileContent = await client.getFileContent(css.file.fileKey);
const content = Buffer.from(fileContent.content || "", "base64");
backupFiles.set(`pc/${css.file.name || css.file.fileKey}.css`, content);
backupFileList.push({
type: "pc",
fileType: "css",
fileName: css.file.name || css.file.fileKey,
fileKey: css.file.fileKey,
size: content.length,
path: `pc/${css.file.name || css.file.fileKey}.css`,
});
}
}
// Save backup // Save backup
const backupMetadata: BackupMetadata = { const backupMetadata: BackupMetadata = {
backedUpAt: new Date().toISOString(), backedUpAt: new Date().toISOString(),
@@ -363,59 +357,42 @@ function registerDeploy(): void {
const backupPath = await saveBackup(backupMetadata, backupFiles); const backupPath = await saveBackup(backupMetadata, backupFiles);
// Upload new files // Upload new files and build customization config directly
const uploadedFiles: Array<{ const newConfig: AppCustomizeParameter = {
type: "js" | "css"; app: params.appId,
position: string; scope: "ALL",
fileKey: string; desktop: {
}> = []; js: [],
css: [],
},
mobile: {
js: [],
css: [],
},
};
for (const file of params.files) { for (const file of params.files) {
const fileKey = await client.uploadFile(file.content, file.fileName); const fileKey = await client.uploadFile(file.content, file.fileName);
const fileEntry = { type: "FILE" as const, file: { fileKey: fileKey.fileKey } };
uploadedFiles.push({ // Add to corresponding field based on file type and position
type: file.fileType, if (file.fileType === "js") {
position: file.position, if (file.position.startsWith("pc_")) {
fileKey: fileKey.fileKey, 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);
}
}
} }
// Build new customization config
// Note: This is simplified - real implementation would merge with existing config
const newConfig: AppCustomizeResponse = {
desktop: {
js: uploadedFiles
.filter((f) => f.type === "js" && f.position.startsWith("pc_"))
.map((f) => ({
type: "FILE" as const,
file: { fileKey: f.fileKey },
})),
css: uploadedFiles
.filter((f) => f.type === "css" && f.position === "pc_css")
.map((f) => ({
type: "FILE" as const,
file: { fileKey: f.fileKey },
})),
},
mobile: {
js: uploadedFiles
.filter((f) => f.type === "js" && f.position.startsWith("mobile_"))
.map((f) => ({
type: "FILE" as const,
file: { fileKey: f.fileKey },
})),
css: uploadedFiles
.filter((f) => f.type === "css" && f.position === "mobile_css")
.map((f) => ({
type: "FILE" as const,
file: { fileKey: f.fileKey },
})),
},
scope: "ALL",
revision: "-1",
};
// Update app customization // Update app customization
await client.updateAppCustomize(params.appId, newConfig);
// Deploy the changes // Deploy the changes
await client.deployApp(params.appId); await client.deployApp(params.appId);
@@ -434,9 +411,7 @@ function registerDeploy(): void {
* Download files from Kintone * Download files from Kintone
*/ */
function registerDownload(): void { function registerDownload(): void {
handleWithParams<DownloadParams, DownloadResult>( handle<DownloadParams, DownloadResult>("download", async (params) => {
"download",
async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
const domainWithPassword = await getDomain(params.domainId); const domainWithPassword = await getDomain(params.domainId);
@@ -466,20 +441,21 @@ function registerDownload(): void {
? appDetail.customization?.mobile?.js ? appDetail.customization?.mobile?.js
: appDetail.customization?.mobile?.css; : appDetail.customization?.mobile?.css;
for (const file of files || []) { for (const [index, file] of (files || []).entries()) {
if (file.file?.fileKey) { const fileKey = getFileKey(file);
const content = await client.getFileContent(file.file.fileKey); if (fileKey) {
const content = await client.getFileContent(fileKey);
const buffer = Buffer.from(content.content || "", "base64"); const buffer = Buffer.from(content.content || "", "base64");
const fileName = file.file.name || file.file.fileKey;
const type = fileType.includes("mobile") ? "mobile" : "pc";
const ext = fileType.includes("js") ? "js" : "css"; 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); downloadFiles.set(`${type}/${fileName}`, buffer);
downloadFileList.push({ downloadFileList.push({
type, type,
fileType: ext, fileType: ext,
fileName, fileName,
fileKey: file.file.fileKey, fileKey,
size: buffer.length, size: buffer.length,
path: `${type}/${fileName}`, path: `${type}/${fileName}`,
}); });
@@ -507,8 +483,7 @@ function registerDownload(): void {
path, path,
metadata, metadata,
}; };
}, });
);
} }
// ==================== Version IPC Handlers ==================== // ==================== Version IPC Handlers ====================
@@ -517,19 +492,16 @@ function registerDownload(): void {
* Get versions for an app * Get versions for an app
*/ */
function registerGetVersions(): void { function registerGetVersions(): void {
handleWithParams<GetVersionsParams, Version[]>( handle<GetVersionsParams, Version[]>("getVersions", async (params) => {
"getVersions",
async (params) => {
return listVersions(params.domainId, params.appId); return listVersions(params.domainId, params.appId);
}, });
);
} }
/** /**
* Delete a version * Delete a version
*/ */
function registerDeleteVersion(): void { function registerDeleteVersion(): void {
handleWithParams<string, void>("deleteVersion", async (id) => { handle<string, void>("deleteVersion", async (id) => {
return deleteVersion(id); return deleteVersion(id);
}); });
} }
@@ -538,7 +510,7 @@ function registerDeleteVersion(): void {
* Rollback to a previous version * Rollback to a previous version
*/ */
function registerRollback(): void { function registerRollback(): void {
handleWithParams<RollbackParams, DeployResult>("rollback", async (_params) => { handle<RollbackParams, DeployResult>("rollback", async (_params) => {
// This would read the version file and redeploy // This would read the version file and redeploy
// Simplified implementation - would need full implementation // Simplified implementation - would need full implementation
throw new Error("Rollback not yet implemented"); throw new Error("Rollback not yet implemented");
@@ -577,3 +549,4 @@ export function registerIpcHandlers(): void {
console.log("IPC handlers registered"); console.log("IPC handlers registered");
} }

View File

@@ -1,14 +1,13 @@
import { KintoneRestAPIClient } from "@kintone/rest-api-client"; import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import type { KintoneRestAPIError } from "@kintone/rest-api-client"; import type { KintoneRestAPIError } from '@kintone/rest-api-client';
import type { DomainWithPassword } from "@shared/types/domain"; import type { DomainWithPassword } from '@shared/types/domain';
import type { import {
AppResponse, type AppResponse,
AppCustomizeResponse, type AppDetail,
AppDetail, type FileContent,
FileContent, type KintoneApiError,
KintoneApiError, AppCustomizeParameter,
FileConfig, } from '@shared/types/kintone';
} from "@shared/types/kintone";
/** /**
* Custom error class for Kintone API errors * Custom error class for Kintone API errors
@@ -18,13 +17,9 @@ export class KintoneError extends Error {
public readonly id?: string; public readonly id?: string;
public readonly statusCode?: number; public readonly statusCode?: number;
constructor( constructor(message: string, apiError?: KintoneApiError, statusCode?: number) {
message: string,
apiError?: KintoneApiError,
statusCode?: number,
) {
super(message); super(message);
this.name = "KintoneError"; this.name = 'KintoneError';
this.code = apiError?.code; this.code = apiError?.code;
this.id = apiError?.id; this.id = apiError?.id;
this.statusCode = statusCode; this.statusCode = statusCode;
@@ -34,7 +29,7 @@ export class KintoneError extends Error {
/** /**
* Kintone REST API Client * Kintone REST API Client
*/ */
export class SelfKintoneClient { export class KintoneClient {
private client: KintoneRestAPIClient; private client: KintoneRestAPIClient;
private domain: string; private domain: string;
@@ -42,8 +37,8 @@ export class SelfKintoneClient {
this.domain = domainConfig.domain; this.domain = domainConfig.domain;
const auth = const auth =
domainConfig.authType === "api_token" domainConfig.authType === 'api_token'
? { apiToken: domainConfig.apiToken || "" } ? { apiToken: domainConfig.apiToken || '' }
: { : {
username: domainConfig.username, username: domainConfig.username,
password: domainConfig.password, password: domainConfig.password,
@@ -56,7 +51,7 @@ export class SelfKintoneClient {
} }
private convertError(error: unknown): KintoneError { private convertError(error: unknown): KintoneError {
if (error && typeof error === "object" && "code" in error) { if (error && typeof error === 'object' && 'code' in error) {
const apiError = error as KintoneRestAPIError; const apiError = error as KintoneRestAPIError;
return new KintoneError( return new KintoneError(
apiError.message, apiError.message,
@@ -69,7 +64,7 @@ export class SelfKintoneClient {
return new KintoneError(error.message); return new KintoneError(error.message);
} }
return new KintoneError("Unknown error occurred"); return new KintoneError('Unknown error occurred');
} }
private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> { private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> {
@@ -80,31 +75,13 @@ export class SelfKintoneClient {
} }
} }
private buildCustomizeSection(
files?: FileConfig[],
): NonNullable<
Parameters<KintoneRestAPIClient["app"]["updateAppCustomize"]>[0]["desktop"]
>["js"] {
if (!files || files.length === 0) return undefined;
return files.map((item) => {
if (item.type === "FILE" && item.file) {
return { type: "FILE" as const, file: { fileKey: item.file.fileKey } };
}
return { type: "URL" as const, url: item.url || "" };
});
}
// ==================== App APIs ==================== // ==================== App APIs ====================
/** /**
* Get all apps with pagination support * Get all apps with pagination support
* Fetches all apps by making multiple requests if needed * Fetches all apps by making multiple requests if needed
*/ */
async getApps(options?: { async getApps(options?: { limit?: number; offset?: number }): Promise<AppResponse[]> {
limit?: number;
offset?: number;
}): Promise<AppResponse[]> {
return this.withErrorHandling(async () => { return this.withErrorHandling(async () => {
// If pagination options provided, use them directly // If pagination options provided, use them directly
if (options?.limit !== undefined || options?.offset !== undefined) { if (options?.limit !== undefined || options?.offset !== undefined) {
@@ -157,23 +134,19 @@ export class SelfKintoneClient {
return this.withErrorHandling(async () => { return this.withErrorHandling(async () => {
const data = await this.client.file.downloadFile({ fileKey }); const data = await this.client.file.downloadFile({ fileKey });
const buffer = Buffer.from(data); const buffer = Buffer.from(data);
const content = buffer.toString("base64"); const content = buffer.toString('base64');
return { return {
fileKey, fileKey,
name: fileKey, name: fileKey,
size: buffer.byteLength, size: buffer.byteLength,
mimeType: "application/octet-stream", mimeType: 'application/octet-stream',
content, content,
}; };
}); });
} }
async uploadFile( async uploadFile(content: string | Buffer, fileName: string, _mimeType?: string): Promise<{ fileKey: string }> {
content: string | Buffer,
fileName: string,
_mimeType?: string,
): Promise<{ fileKey: string }> {
return this.withErrorHandling(async () => { return this.withErrorHandling(async () => {
const response = await this.client.file.uploadFile({ const response = await this.client.file.uploadFile({
file: { name: fileName, data: content }, file: { name: fileName, data: content },
@@ -184,23 +157,9 @@ export class SelfKintoneClient {
// ==================== Deploy APIs ==================== // ==================== Deploy APIs ====================
async updateAppCustomize( async updateAppCustomize(appId: string, config: Omit<AppCustomizeParameter, 'app'>): Promise<void> {
appId: string,
config: AppCustomizeResponse,
): Promise<void> {
return this.withErrorHandling(async () => { return this.withErrorHandling(async () => {
await this.client.app.updateAppCustomize({ await this.client.app.updateAppCustomize({ ...config, app: appId });
app: appId,
desktop: {
js: this.buildCustomizeSection(config.desktop?.js),
css: this.buildCustomizeSection(config.desktop?.css),
},
mobile: {
js: this.buildCustomizeSection(config.mobile?.js),
css: this.buildCustomizeSection(config.mobile?.css),
},
scope: config.scope || "ALL",
});
}); });
} }
@@ -210,12 +169,10 @@ export class SelfKintoneClient {
}); });
} }
async getDeployStatus( async getDeployStatus(appId: string): Promise<'PROCESSING' | 'SUCCESS' | 'FAIL' | 'CANCEL'> {
appId: string,
): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
return this.withErrorHandling(async () => { return this.withErrorHandling(async () => {
const response = await this.client.app.getDeployStatus({ apps: [appId] }); const response = await this.client.app.getDeployStatus({ apps: [appId] });
return response.apps[0]?.status || "FAIL"; return response.apps[0]?.status || 'FAIL';
}); });
} }
@@ -229,8 +186,7 @@ export class SelfKintoneClient {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error: error instanceof KintoneError ? error.message : 'Connection failed',
error instanceof KintoneError ? error.message : "Connection failed",
}; };
} }
} }
@@ -240,8 +196,6 @@ export class SelfKintoneClient {
} }
} }
export function createKintoneClient( export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
domain: DomainWithPassword, return new KintoneClient(domain);
): SelfKintoneClient {
return new SelfKintoneClient(domain);
} }

View File

@@ -45,7 +45,6 @@ export interface SelfAPI {
) => Promise<Result<boolean>>; ) => Promise<Result<boolean>>;
// ==================== Browse ==================== // ==================== Browse ====================
getSpaces: (params: { domainId: string }) => Promise<Result<KintoneSpace[]>>;
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>; getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>; getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
getFileContent: ( getFileContent: (

View File

@@ -24,8 +24,8 @@ import { createStyles } from "antd-style";
import { useAppStore } from "@renderer/stores"; import { useAppStore } from "@renderer/stores";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import { CodeViewer } from "../CodeViewer"; import { CodeViewer } from "../CodeViewer";
import { FileConfig } from "@shared/types/kintone"; import { FileConfigResponse } from "@shared/types/kintone";
import { getDisplayName, getFileKey, isFileUpload } from "@shared/utils/fileDisplay";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
container: css` container: css`
height: 100%; height: 100%;
@@ -129,7 +129,7 @@ const AppDetail: React.FC = () => {
const [activeTab, setActiveTab] = React.useState("info"); const [activeTab, setActiveTab] = React.useState("info");
const [selectedFile, setSelectedFile] = React.useState<{ const [selectedFile, setSelectedFile] = React.useState<{
type: "js" | "css"; type: "js" | "css";
fileKey: string; fileKey?: string;
name: string; name: string;
} | null>(null); } | null>(null);
@@ -168,7 +168,7 @@ const AppDetail: React.FC = () => {
} }
const renderFileList = ( const renderFileList = (
files: (FileConfig)[] | undefined, files: (FileConfigResponse)[] | undefined,
type: "js" | "css", type: "js" | "css",
) => { ) => {
if (!files || files.length === 0) { if (!files || files.length === 0) {
@@ -178,8 +178,9 @@ const AppDetail: React.FC = () => {
return ( return (
<div> <div>
{files.map((file, index) => { {files.map((file, index) => {
const fileName = file.file?.name || file.url || `文件 ${index + 1}`; const fileName = getDisplayName(file, type, index);
const fileKey = file.file?.fileKey; const fileKey = getFileKey(file);
const canView = isFileUpload(file);
return ( return (
<div <div
@@ -202,7 +203,7 @@ const AppDetail: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{fileKey && ( {canView && (
<Space> <Space>
<Button <Button
type="text" type="text"
@@ -313,7 +314,7 @@ const AppDetail: React.FC = () => {
{ {
key: "code", key: "code",
label: "代码查看", label: "代码查看",
children: selectedFile ? ( children: selectedFile && selectedFile.fileKey ? (
<CodeViewer <CodeViewer
fileKey={selectedFile.fileKey} fileKey={selectedFile.fileKey}
fileName={selectedFile.name} fileName={selectedFile.name}

View File

@@ -13,7 +13,7 @@ import {
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useAppStore } from "@renderer/stores"; import { useAppStore } from "@renderer/stores";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { KintoneApp } from "@shared/types/kintone"; import type { AppDetail } from "@shared/types/kintone";
const { Text } = Typography; const { Text } = Typography;
@@ -160,12 +160,12 @@ const AppList: React.FC = () => {
}, [apps, searchText]); }, [apps, searchText]);
// Handle item click // Handle item click
const handleItemClick = (app: KintoneApp) => { const handleItemClick = (app: AppDetail) => {
setSelectedAppId(app.appId); setSelectedAppId(app.appId);
}; };
// Render list item // Render list item
const renderItem = (app: KintoneApp) => { const renderItem = (app: AppDetail) => {
const isActive = selectedAppId === app.appId; const isActive = selectedAppId === app.appId;
return ( return (

View File

@@ -6,10 +6,8 @@
export interface Domain { export interface Domain {
id: string; // UUID id: string; // UUID
name: string; // 自定义名称 name: string; // 自定义名称
domain: string; // Kintone 域名
username: string; // 用户名(邮箱) username: string; // 用户名(邮箱)
authType: "password" | "api_token"; domain: string; // Kintone 域名
apiToken?: string; // 可选,当 authType 为 api_token 时
createdAt: string; // ISO 8601 createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601 updatedAt: string; // ISO 8601
} }

View File

@@ -3,10 +3,9 @@
* Unified request/response format for all IPC handlers * Unified request/response format for all IPC handlers
*/ */
import type { Domain, DomainWithPassword, DomainWithStatus } from "./domain"; import type { Domain, DomainWithStatus } from "./domain";
import type { import type {
AppResponse, AppResponse,
KintoneSpace,
AppDetail, AppDetail,
FileContent, FileContent,
} from "./kintone"; } from "./kintone";
@@ -47,11 +46,6 @@ export interface TestDomainConnectionParams {
} }
// ==================== Browse IPC Types ==================== // ==================== Browse IPC Types ====================
export interface GetSpacesParams {
domainId: string;
}
export interface GetAppsParams { export interface GetAppsParams {
domainId: string; domainId: string;
spaceId?: string; spaceId?: string;
@@ -141,7 +135,6 @@ export interface ElectronAPI {
) => Promise<Result<boolean>>; ) => Promise<Result<boolean>>;
// Browse // Browse
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>; getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>; getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
getFileContent: ( getFileContent: (

View File

@@ -5,20 +5,33 @@
import type { KintoneRestAPIClient } from "@kintone/rest-api-client"; import type { KintoneRestAPIClient } from "@kintone/rest-api-client";
// ==================== SDK Type Extraction ==================== /**
// Use typeof + ReturnType to extract types from SDK methods * API Error - simplified from SDK's KintoneRestAPIError
*/
export interface KintoneApiError {
code: string;
message: string;
id: string;
errors?: Record<string, { messages: string[] }>;
}
// ============== SDK Type Extraction ==============
type KintoneClient = KintoneRestAPIClient; type KintoneClient = KintoneRestAPIClient;
/** App response from getApp/getApps */ /** App response from getApp/getApps */
export type AppResponse = Awaited<ReturnType<KintoneClient["app"]["getApp"]>>; export type AppResponse = Awaited<ReturnType<KintoneClient["app"]["getApp"]>>;
/** App customization request - parameters for updateAppCustomize */
export type AppCustomizeParameter = Parameters<
KintoneClient["app"]["updateAppCustomize"]
>[number];
/** App customization response */ /** App customization response */
export type AppCustomizeResponse = Awaited< export type AppCustomizeResponse = Awaited<
ReturnType<KintoneClient["app"]["getAppCustomize"]> ReturnType<KintoneClient["app"]["getAppCustomize"]>
>; >;
// ==================== Custom Business Types ==================== // ============== Custom Business Types ==============
// These types represent business-layer aggregation or additional metadata
/** /**
* App detail - combines app info + customization * App detail - combines app info + customization
@@ -40,18 +53,48 @@ export interface FileContent {
content?: string; // Base64 encoded or text content?: string; // Base64 encoded or text
} }
/** /**
* File config for customization * File config for customization
* Using SDK's type directly from AppCustomizeResponse * Using SDK's type directly from AppCustomizeResponse
*
* IMPORTANT: The Kintone API does NOT return file names in getAppCustomize response.
* - FILE type: { type: "FILE", file: { fileKey: string } }
* - URL type: { type: "URL", url: string }
*
* Use getDisplayName() utility to get user-friendly display names.
*/ */
export type FileConfig = NonNullable<AppCustomizeResponse["desktop"]["js"]>[number]; export type FileConfigParameter = NonNullable<NonNullable<AppCustomizeParameter["desktop"]>["js"]>[number];
export type FileConfigResponse = NonNullable<AppCustomizeResponse["desktop"]>["js"][number];
// ============== Type Utilities ==============
type ExtractUrlType<T> = T extends { type: "URL" } ? T : never;
export type UrlResourceParameter = ExtractUrlType<FileConfigParameter>;
export type UrlResourceResponse = ExtractUrlType<FileConfigResponse>;
type ExtractFileType<T> = T extends { type: "FILE" } ? T : never;
export type FileResourceParameter = ExtractFileType<FileConfigParameter>;
export type FileResourceResponse = ExtractFileType<FileConfigResponse>;
// ============== Type Guards ==============
/** /**
* API Error - simplified from SDK's KintoneRestAPIError * Check if resource is URL type - works with both Response and Parameter types
* TypeScript will automatically narrow the type based on usage
*/ */
export interface KintoneApiError { export function isUrlResource(
code: string; resource: FileConfigResponse | FileConfigParameter,
message: string; ): resource is UrlResourceParameter | UrlResourceResponse {
id: string; return resource.type === "URL";
errors?: Record<string, { messages: string[] }>; }
/**
* Check if resource is FILE type - works with both Response and Parameter types
* TypeScript will automatically narrow the type based on usage
*/
export function isFileResource(
resource: FileConfigResponse | FileConfigParameter,
): resource is FileResourceParameter | FileResourceResponse {
return resource.type === "FILE";
} }

View File

@@ -0,0 +1,48 @@
import type { FileConfigResponse } from "@shared/types/kintone";
/**
* Get user-friendly display name for a file config
*/
export function getDisplayName(
file: FileConfigResponse,
fileType: "js" | "css",
index: number,
): string {
if (file.type === "URL" && file.url) {
return extractFilenameFromUrl(file.url) || `URL ${index + 1}`;
}
if (file.type === "FILE" && file.file?.fileKey) {
return `${file.file.fileKey}.${fileType}`;
}
return `Unknown File ${index + 1}`;
}
/**
* Extract filename from URL
*/
export function extractFilenameFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const segments = pathname.split("/").filter(Boolean);
return segments.length > 0 ? segments[segments.length - 1] : null;
} catch {
return null;
}
}
/**
* Check if file config is a FILE type upload
*/
export function isFileUpload(file: FileConfigResponse): boolean {
return file.type === "FILE" && !!file.file?.fileKey;
}
/**
* Get fileKey from file config (only for FILE type)
*/
export function getFileKey(file: FileConfigResponse): string | undefined {
return file.type === "FILE" ? file.file?.fileKey : undefined;
}