fix load app, download, upgrade

This commit is contained in:
2026-03-16 15:53:59 +08:00
parent b34720fccf
commit 970d6d9538
16 changed files with 487 additions and 107 deletions

8
package-lock.json generated
View File

@@ -17,7 +17,7 @@
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2",
"@lobehub/ui": "^5.5.0",
"@lobehub/ui": "^5.5.1",
"@uiw/react-codemirror": "^4.23.0",
"antd": "^6.1.0",
"antd-style": "^4.1.0",
@@ -2581,9 +2581,9 @@
}
},
"node_modules/@lobehub/ui": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/@lobehub/ui/-/ui-5.5.0.tgz",
"integrity": "sha512-wVAAl13cYxtFSiJcKPxmQuiKA6ZqHenXDPV7h1vadMjlhTp71PV4ZhtAJffAv7Mni4EjyJL4B6+pEX+nqlxeoQ==",
"version": "5.5.1",
"resolved": "https://registry.npmmirror.com/@lobehub/ui/-/ui-5.5.1.tgz",
"integrity": "sha512-rAZ46Fc633EDgY0eBDEIifmLpSNUr5hHJIL6ra6q7nL0KiCSuIbiQmszA5QyzETKMvF1WW6EbQk4JOyq2E4kyg==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^2.0.3",

View File

@@ -28,7 +28,7 @@
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2",
"@lobehub/ui": "^5.5.0",
"@lobehub/ui": "^5.5.1",
"@uiw/react-codemirror": "^4.23.0",
"antd": "^6.1.0",
"antd-style": "^4.1.0",

View File

@@ -4,7 +4,8 @@
* Based on REQUIREMENTS.md:228-268
*/
import { ipcMain, dialog } from "electron";
import { ipcMain, dialog, app } from "electron";
import { autoUpdater } from "electron-updater";
import { v4 as uuidv4 } from "uuid";
import {
saveDomain,
@@ -34,6 +35,7 @@ import type {
GetVersionsParams,
RollbackParams,
SetLocaleParams,
CheckUpdateResult,
} from "@shared/types/ipc";
import type { LocaleCode } from "@shared/types/locale";
import type {
@@ -539,6 +541,69 @@ function registerSetLocale(): void {
});
}
// ==================== 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 ====================
/**
@@ -573,6 +638,10 @@ export function registerIpcHandlers(): void {
registerGetLocale();
registerSetLocale();
// App Version & Update
registerGetAppVersion();
registerCheckForUpdates();
// Dialog
registerShowSaveDialog();
registerSaveFileContent();

View File

@@ -16,6 +16,7 @@ import type {
SetLocaleParams,
ShowSaveDialogParams,
SaveFileContentParams,
CheckUpdateResult,
} from "@shared/types/ipc";
import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type {
@@ -70,6 +71,10 @@ export interface SelfAPI {
getLocale: () => Promise<Result<LocaleCode>>;
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
// ==================== App Version & Update ====================
getAppVersion: () => Promise<Result<string>>;
checkForUpdates: () => Promise<Result<CheckUpdateResult>>;
// ==================== Dialog ====================
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;

View File

@@ -34,10 +34,13 @@ const api: SelfAPI = {
getLocale: () => ipcRenderer.invoke("getLocale"),
setLocale: (params) => ipcRenderer.invoke("setLocale", params),
// App Version & Update
getAppVersion: () => ipcRenderer.invoke("getAppVersion"),
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
// Dialog
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
};
// Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -4,15 +4,13 @@
*/
import React from "react";
import { motion } from "motion/react";
import { useTranslation } from "react-i18next";
import { Input, Spin, Typography, Space } from "antd";
import { Button, Tooltip, Empty, Select } from "@lobehub/ui";
import { Spin, Typography, Space } from "antd";
import { Button, Tooltip, Empty, Select, Input } from "@lobehub/ui";
import {
RefreshCw,
Search,
LayoutGrid,
Pin,
ArrowUpDown,
ArrowDownUp,
} from "lucide-react";
@@ -21,9 +19,9 @@ import { useAppStore } from "@renderer/stores";
import { useDomainStore } from "@renderer/stores";
import { useUIStore } from "@renderer/stores";
import type { AppDetail } from "@shared/types/kintone";
import AppListItem from "./AppListItem";
const { Text } = Typography;
const useStyles = createStyles(({ token, css }) => ({
container: css`
height: 100%;
@@ -157,74 +155,6 @@ const useStyles = createStyles(({ token, css }) => ({
`,
}));
interface AppListItemProps {
app: AppDetail;
isActive: boolean;
isPinned: boolean;
onItemClick: (app: AppDetail) => void;
onPinToggle: (e: React.MouseEvent, appId: string) => void;
styles: ReturnType<typeof useStyles>["styles"];
t: (key: string) => string;
}
const AppListItem: React.FC<AppListItemProps> = ({
app,
isActive,
isPinned,
onItemClick,
onPinToggle,
styles,
t,
}) => {
const [isHovered, setIsHovered] = React.useState(false);
// Pin overlay is visible when:
// 1. Item is pinned (always show)
// 2. Item is hovered (show for unpinned items)
const showPinOverlay = isPinned || isHovered;
return (
<motion.div
layout
className={`${styles.listItemMotion} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
onClick={() => onItemClick(app)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<div className={styles.appInfoWrapper}>
<div className={styles.iconWrapper}>
{/* Pin overlay - visible on hover for unpinned, always visible for pinned */}
<div
className={`${styles.pinOverlay} ${showPinOverlay ? styles.pinOverlayVisible : ""}`}
onClick={(e) => onPinToggle(e, app.appId)}
>
<Tooltip title={isPinned ? t("unpin") : t("pinApp")}>
<span
className={`${styles.pinButton} ${isPinned ? styles.pinButtonPinned : ""}`}
>
{isPinned ? (
<Pin size={16} className="fill-current" />
) : (
<Pin size={16} />
)}
</span>
</Tooltip>
</div>
{/* App icon - hidden when pin overlay is visible */}
{!showPinOverlay && <LayoutGrid size={16} style={{ color: "#1890ff" }} />}
</div>
<Tooltip title={app.name}>
<span className={styles.appName}>{app.name}</span>
</Tooltip>
<Text code className={styles.appId}>
ID: {app.appId}
</Text>
</div>
</motion.div>
);
};
const AppList: React.FC = () => {
const { t } = useTranslation("app");
const { styles } = useStyles();

View File

@@ -0,0 +1,166 @@
/**
* AppListItem Component
* Individual app item in the app list with pinning support
*/
import React from "react";
import { motion } from "motion/react";
import { Typography } from "antd";
import { Tooltip } from "@lobehub/ui";
import { Pin, LayoutGrid } from "lucide-react";
import type { AppDetail } from "@shared/types/kintone";
const { Text } = Typography;
// Styles for AppListItem - defined inline to be self-contained
const listItemStyles = {
listItemMotion: {
cursor: "pointer",
padding: "8px 16px !important",
borderBottom:
"1px solid var(--ant-color-border-secondary, #f0f0f0) !important",
position: "relative" as const,
},
listItemActive: {
background: "var(--ant-color-primary-bg-hover, #e6f7ff) !important",
borderLeft: "3px solid var(--ant-color-primary, #1890ff) !important",
},
listItemPinned: {
background: "var(--ant-color-warning-bg, #fffbe6) !important",
},
appInfoWrapper: {
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
minWidth: 0,
position: "relative" as const,
},
iconWrapper: {
position: "relative" as const,
width: "20px",
height: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
pinOverlay: {
position: "absolute" as const,
top: 0,
left: 0,
width: "20px",
height: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0,
transition: "opacity 0.15s ease",
zIndex: 1,
},
appName: {
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
},
appId: {
fontFamily: "monospace",
fontSize: "12px",
color: "var(--ant-color-text-secondary, #8c8c8c)",
flexShrink: 0,
},
pinButton: {
color: "var(--ant-color-text-tertiary, #bfbfbf)",
cursor: "pointer",
fontSize: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
};
export interface AppListItemProps {
app: AppDetail;
isActive: boolean;
isPinned: boolean;
onItemClick: (app: AppDetail) => void;
onPinToggle: (e: React.MouseEvent, appId: string) => void;
t: (key: string) => string;
styles: {
listItemMotion: string;
listItemActive: string;
listItemPinned: string;
appInfoWrapper: string;
iconWrapper: string;
pinOverlay: string;
pinOverlayVisible: string;
pinButton: string;
pinButtonPinned: string;
appName: string;
appId: string;
};
}
const AppListItem: React.FC<AppListItemProps> = ({
app,
isActive,
isPinned,
onItemClick,
onPinToggle,
styles,
t,
}) => {
const [isHovered, setIsHovered] = React.useState(false);
// Pin overlay is visible when:
// 1. Item is pinned (always show)
// 2. Item is hovered (show for unpinned items)
const showPinOverlay = isPinned || isHovered;
return (
<motion.div
layout
className={`${styles.listItemMotion} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
onClick={() => onItemClick(app)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<div className={styles.appInfoWrapper}>
<div className={styles.iconWrapper}>
{/* Pin overlay - visible on hover for unpinned, always visible for pinned */}
<div
className={`${styles.pinOverlay} ${showPinOverlay ? styles.pinOverlayVisible : ""}`}
onClick={(e) => onPinToggle(e, app.appId)}
>
<Tooltip title={isPinned ? t("unpin") : t("pinApp")}>
<span
className={`${styles.pinButton} ${isPinned ? styles.pinButtonPinned : ""}`}
>
{isPinned ? (
<Pin size={16} className="fill-current" />
) : (
<Pin size={16} />
)}
</span>
</Tooltip>
</div>
{/* App icon - hidden when pin overlay is visible */}
{!showPinOverlay && (
<LayoutGrid size={16} style={{ color: "#1890ff" }} />
)}
</div>
<Tooltip title={app.name}>
<span className={styles.appName}>{app.name}</span>
</Tooltip>
<Text code className={styles.appId}>
ID: {app.appId}
</Text>
</div>
</motion.div>
);
};
export default AppListItem;

View File

@@ -7,10 +7,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Spin, Alert, Space, message } from "antd";
import { Button, Empty } from "@lobehub/ui";
import {
Copy,
Download,
} from "lucide-react";
import { Copy, Download } from "lucide-react";
import { createStyles } from "antd-style";
import CodeMirror from "@uiw/react-codemirror";
import { javascript } from "@codemirror/lang-javascript";
@@ -72,6 +69,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
const [error, setError] = React.useState<string | null>(null);
const [content, setContent] = React.useState<string>("");
const [language, setLanguage] = React.useState<"js" | "css">(fileType);
const [downloading, setDownloading] = React.useState(false);
// Load file content
React.useEffect(() => {
@@ -128,17 +126,62 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
message.success(t("copiedToClipboard"));
};
const handleDownload = () => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const handleDownload = async () => {
if (!currentDomain || downloading) return;
// Check if fileName already has extension
const hasExt = /\.(js|css)$/i.test(fileName);
const finalFileName = hasExt ? fileName : `${fileName}.${fileType}`;
setDownloading(true);
try {
// 1. Show save dialog
const dialogResult = await window.api.showSaveDialog({
defaultPath: finalFileName,
});
if (!dialogResult.success || !dialogResult.data) {
// User cancelled
setDownloading(false);
return;
}
const savePath = dialogResult.data;
// 2. Get current file content (already decoded in state)
const encoder = new TextEncoder();
const uint8Array = encoder.encode(content);
// Convert to base64 for IPC transfer
let base64 = "";
const chunkSize = 8192;
for (let i = 0; i < uint8Array.length; i += chunkSize) {
base64 += String.fromCharCode.apply(
null,
Array.from(uint8Array.slice(i, i + chunkSize)),
);
}
const base64Content = btoa(base64);
// 3. Save to selected path
const saveResult = await window.api.saveFileContent({
filePath: savePath,
content: base64Content,
});
if (saveResult.success) {
message.success(t("downloadSuccess"));
} else {
message.error(
saveResult.error || t("downloadFailed", { ns: "common" }),
);
}
} catch (error) {
console.error("Download failed:", error);
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloading(false);
}
};
if (loading) {
@@ -170,9 +213,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
if (!content) {
return (
<div className={styles.container}>
<Empty
description={t("fileEmpty")}
/>
<Empty description={t("fileEmpty")} />
</div>
);
}
@@ -194,6 +235,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
type="text"
size="small"
icon={<Download size={16} />}
loading={downloading}
onClick={handleDownload}
>
{t("download", { ns: "common" })}

View File

@@ -100,6 +100,9 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
useDomainStore();
const handleSelect = (domain: Domain) => {
if (currentDomain?.id === domain.id) {
return;
}
switchDomain(domain);
};

View File

@@ -242,7 +242,6 @@ const DomainManager: React.FC<DomainManagerProps> = ({
<h2 className={styles.title}>{t("domainManagement")}</h2>
<div className={styles.actions}>
<Button type="primary" icon={<Plus size={16} />} onClick={handleAdd}>
{t("add")}
</Button>
</div>
</div>

View File

@@ -5,8 +5,9 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Typography, Radio, Divider } from "antd";
import { Globe } from "lucide-react";
import { Typography, Radio, Divider, Space, message } from "antd";
import { Globe, Info, ExternalLink } from "lucide-react";
import { Button } from "@lobehub/ui";
import { createStyles } from "antd-style";
import { useLocaleStore } from "@renderer/stores/localeStore";
@@ -42,6 +43,28 @@ const useStyles = createStyles(({ token, css }) => ({
display: none;
}
`,
versionInfo: css`
display: flex;
align-items: center;
gap: ${token.marginSM}px;
color: ${token.colorTextSecondary};
font-size: 14px;
`,
versionNumber: css`
font-family: monospace;
font-weight: 500;
`,
updateInfo: css`
margin-top: ${token.marginSM}px;
padding: ${token.paddingSM}px ${token.paddingMD}px;
background: ${token.colorInfoBg};
border-radius: ${token.borderRadius}px;
font-size: 13px;
`,
updateAvailable: css`
background: ${token.colorSuccessBg};
color: ${token.colorSuccessText};
`,
}));
const Settings: React.FC = () => {
@@ -50,6 +73,25 @@ const Settings: React.FC = () => {
const { locale, setLocale } = useLocaleStore();
const i18n = useTranslation().i18n;
// Version and update state
const [appVersion, setAppVersion] = React.useState<string>("");
const [checkingUpdate, setCheckingUpdate] = React.useState(false);
const [updateInfo, setUpdateInfo] = React.useState<{
hasUpdate: boolean;
version?: string;
} | null>(null);
// Load app version on mount
React.useEffect(() => {
const loadVersion = async () => {
const result = await window.api.getAppVersion();
if (result.success) {
setAppVersion(result.data);
}
};
loadVersion();
}, []);
const handleLocaleChange = async (newLocale: LocaleCode) => {
setLocale(newLocale);
i18n.changeLanguage(newLocale);
@@ -57,6 +99,33 @@ const Settings: React.FC = () => {
await window.api.setLocale({ locale: newLocale });
};
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
setUpdateInfo(null);
try {
const result = await window.api.checkForUpdates();
if (result.success) {
setUpdateInfo({
hasUpdate: result.data.hasUpdate,
version: result.data.updateInfo?.version,
});
} else {
message.error(t("checkUpdateFailed"));
}
} catch {
message.error(t("checkUpdateFailed"));
} finally {
setCheckingUpdate(false);
}
};
const handleOpenReleasePage = () => {
// Open GitHub releases page (or configured update URL)
const releaseUrl = "https://github.com/example/kintone-customize-manager/releases";
window.electron?.shell?.openExternal?.(releaseUrl);
};
return (
<div className={styles.container}>
{/* Language Section */}
@@ -84,7 +153,58 @@ const Settings: React.FC = () => {
<Divider />
{/* Future settings sections can be added here */}
{/* About Section */}
<div className={styles.section}>
<div className={styles.sectionTitle}>
<Info size={16} />
<Title level={5} style={{ margin: 0 }}>
{t("about")}
</Title>
</div>
<div className={styles.versionInfo}>
<span>{t("version")}:</span>
<span className={styles.versionNumber}>{appVersion || "-"}</span>
</div>
<Space direction="vertical" style={{ width: "100%", marginTop: 12 }}>
<Button
type="primary"
icon={<ExternalLink size={14} />}
onClick={handleCheckUpdate}
loading={checkingUpdate}
>
{t("checkUpdate")}
</Button>
{updateInfo && (
<div
className={`${styles.updateInfo} ${
updateInfo.hasUpdate ? styles.updateAvailable : ""
}`}
>
{updateInfo.hasUpdate ? (
<Space direction="vertical" size={4}>
<span>
{t("updateAvailable")}: v{updateInfo.version}
</span>
<Button
type="link"
size="small"
icon={<ExternalLink size={12} />}
onClick={handleOpenReleasePage}
style={{ padding: 0, height: "auto" }}
>
{t("downloadUpdate")}
</Button>
</Space>
) : (
<span>{t("noUpdates")}</span>
)}
</div>
)}
</Space>
</div>
</div>
);
};

View File

@@ -21,6 +21,8 @@
"about": "About",
"version": "Version",
"checkUpdate": "Check for Updates",
"checkUpdateFailed": "Failed to check for updates",
"noUpdates": "You're up to date",
"updateAvailable": "Update available"
"updateAvailable": "Update available",
"downloadUpdate": "Download Update"
}

View File

@@ -21,6 +21,8 @@
"about": "アプリについて",
"version": "バージョン",
"checkUpdate": "更新を確認",
"checkUpdateFailed": "更新の確認に失敗しました",
"noUpdates": "最新バージョンです",
"updateAvailable": "新しいバージョンが利用可能です"
"updateAvailable": "新しいバージョンが利用可能です",
"downloadUpdate": "更新をダウンロード"
}

View File

@@ -21,6 +21,8 @@
"about": "关于",
"version": "版本",
"checkUpdate": "检查更新",
"checkUpdateFailed": "检查更新失败",
"noUpdates": "已是最新版本",
"updateAvailable": "有新版本可用"
"updateAvailable": "有新版本可用",
"downloadUpdate": "下载更新"
}

View File

@@ -4,6 +4,7 @@
*/
import { create } from "zustand";
import { useAppStore } from "./appStore";
import { persist } from "zustand/middleware";
import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { ConnectionStatus } from "@shared/types/domain";
@@ -217,8 +218,17 @@ export const useDomainStore = create<DomainState>()(
},
switchDomain: async (domain: Domain) => {
const appStore = useAppStore.getState();
// 1. reset
appStore.setLoading(true);
appStore.setApps([]);
appStore.setSelectedAppId(null);
// 2. Set current domain
set({ currentDomain: domain });
// Test connection after switching
// 3. Test connection after switching
const status = await get().testConnection(domain.id);
if (status) {
set({
@@ -228,6 +238,19 @@ export const useDomainStore = create<DomainState>()(
},
});
}
// 4. Auto-load apps for the new domain
try {
const result = await window.api.getApps({ domainId: domain.id });
if (result.success) {
appStore.setApps(result.data);
}
} catch (error) {
// Silent fail - user can manually reload
console.error("Failed to auto-load apps:", error);
} finally {
appStore.setLoading(false);
}
},
testConnection: async (id: string) => {

View File

@@ -125,6 +125,20 @@ export interface ShowSaveDialogParams {
export interface SaveFileContentParams {
filePath: string;
content: string; // Base64 encoded
// ==================== App Version & Update IPC Types ====================
export interface UpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string;
}
export interface CheckUpdateResult {
hasUpdate: boolean;
updateInfo?: UpdateInfo;
}
}
// ==================== IPC API Interface ====================