diff --git a/README.md b/README.md
index dafb5d5..4c5c8c0 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,8 @@ _최신 릴리즈는 [GitHub Releases](https://github.com/0-ROK/RiSA/releases/la
- **[RiSA Web](https://ri-sa-kklc.vercel.app/)**: 실제 앱과 동일한 사용자 흐름을 체험하며 주요 탭과 기능을 탐색할 수 있습니다.
- 웹에서 작업하다가 로컬 설치가 필요하면 화면 하단의 다운로드 팝업에서 데스크톱 설치 파일을 받을 수 있습니다.
+- 브라우저에서도 RSA 키 생성과 암·복호화를 체험할 수 있으며, 생성된 키는 로컬 스토리지에 안전하게 보관됩니다.
+- 브라우저 로컬 스토리지에 저장된 데이터는 30일이 지나면 자동으로 삭제되어 장기 보관을 방지합니다.
- 팝업은 GitHub Releases의 최신 버전을 조회해 운영체제에 맞는 설치 파일 링크를 직접 제공합니다.
- macOS 사용자는 애플 실리콘과 인텔 아키텍처를 자동으로 감지해 올바른 설치 파일을 안내받습니다.
- 팝업은 닫아도 현재 방문 중에만 숨겨지며, 페이지를 다시 열면 자동으로 나타납니다.
diff --git a/src/renderer/components/WebDownloadPrompt.tsx b/src/renderer/components/WebDownloadPrompt.tsx
index f0fa61c..f4cb625 100644
--- a/src/renderer/components/WebDownloadPrompt.tsx
+++ b/src/renderer/components/WebDownloadPrompt.tsx
@@ -338,7 +338,7 @@ const WebDownloadPrompt: React.FC = () => {
로컬 앱을 설치하면 모든 RSA 암·복호화 기능을 사용할 수 있어요.
- 웹 데모에서는 키 생성과 암·복호화가 제한되므로, 데스크톱 앱을 다운로드해 보세요.
+ 브라우저에서도 키 생성과 암·복호화를 체험할 수 있지만, 민감한 데이터는 데스크톱 앱에서 처리하는 것이 더 안전합니다. 로컬에 저장된 데이터는 30일 후 자동으로 정리됩니다.
{statusMessage(loading, error, asset)}
diff --git a/src/renderer/pages/KeyManagerPage.tsx b/src/renderer/pages/KeyManagerPage.tsx
index 971efdf..6a07190 100644
--- a/src/renderer/pages/KeyManagerPage.tsx
+++ b/src/renderer/pages/KeyManagerPage.tsx
@@ -58,15 +58,6 @@ const KeyManagerPage: React.FC = () => {
const [selectedEditAlgorithm, setSelectedEditAlgorithm] = useState<'RSA-OAEP' | 'RSA-PKCS1'>('RSA-OAEP');
const handleGenerateKey = async (values: { name: string; keySize: number; preferredAlgorithm: 'RSA-OAEP' | 'RSA-PKCS1' }) => {
- if (isWebEnvironment) {
- notification.info({
- message: '웹 데모 제한',
- description: '웹 데모에서는 RSA 키 생성을 지원하지 않습니다. 데스크톱 버전에서 키를 생성하거나 직접 등록 기능을 사용해주세요.',
- placement: 'topRight',
- });
- return;
- }
-
setGenerateLoading(true);
try {
const keyPair = await services.crypto.generateKeyPair(values.keySize);
@@ -86,8 +77,9 @@ const KeyManagerPage: React.FC = () => {
form.resetFields();
message.success(`"${values.name}" 키가 생성되었습니다.`);
} catch (error) {
- message.error('키 생성 중 오류가 발생했습니다.');
- console.error(error);
+ const messageText = error instanceof Error ? error.message : '키 생성 중 오류가 발생했습니다.';
+ message.error(messageText);
+ console.error('Key generation failed:', error);
} finally {
setGenerateLoading(false);
}
@@ -399,18 +391,17 @@ const KeyManagerPage: React.FC = () => {
display: 'flex',
flexDirection: 'column'
}}>
- }
extra={
-
+
}
onClick={() => setGenerateModalVisible(true)}
size="large"
- disabled={isWebEnvironment}
>
새 키 생성
@@ -438,8 +429,8 @@ const KeyManagerPage: React.FC = () => {
)}
diff --git a/src/renderer/pages/MainPage.tsx b/src/renderer/pages/MainPage.tsx
index 4818ab8..52509bb 100644
--- a/src/renderer/pages/MainPage.tsx
+++ b/src/renderer/pages/MainPage.tsx
@@ -59,14 +59,6 @@ const MainPage: React.FC = () => {
const [decryptionStatus, setDecryptionStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [lastError, setLastError] = useState('');
- const showWebRestrictionNotice = () => {
- notification.info({
- message: '웹 데모 제한',
- description: '웹 데모에서는 RSA 암·복호화 기능을 사용할 수 없습니다. 데스크톱 버전에서 전체 기능을 이용해주세요.',
- placement: 'topRight',
- });
- };
-
// 선택된 키가 변경될 때마다 selectedKey 업데이트 및 알고리즘 자동 설정
useEffect(() => {
const key = keys.find(k => k.id === selectedKeyId);
@@ -79,11 +71,6 @@ const MainPage: React.FC = () => {
}, [selectedKeyId, keys, selectKey]);
const handleEncrypt = async () => {
- if (isWebEnvironment) {
- showWebRestrictionNotice();
- return;
- }
-
if (!encryptText.trim()) {
message.error('암호화할 텍스트를 입력해주세요.');
return;
@@ -157,11 +144,6 @@ const MainPage: React.FC = () => {
};
const handleDecrypt = async () => {
- if (isWebEnvironment) {
- showWebRestrictionNotice();
- return;
- }
-
if (!decryptText.trim()) {
message.error('복호화할 텍스트를 입력해주세요.');
return;
@@ -512,7 +494,7 @@ const MainPage: React.FC = () => {
const renderTabBarExtraContent = () => {
const isEncryptTab = activeTab === 'encrypt';
const hasInputText = (isEncryptTab ? encryptText : decryptText).trim();
- const canExecute = !isWebEnvironment && !!selectedKey && !!hasInputText && !loading;
+ const canExecute = !!selectedKey && !!hasInputText && !loading;
return (
@@ -560,8 +542,8 @@ const MainPage: React.FC = () => {
)}
diff --git a/src/renderer/services/browser/browserChainService.ts b/src/renderer/services/browser/browserChainService.ts
new file mode 100644
index 0000000..9519344
--- /dev/null
+++ b/src/renderer/services/browser/browserChainService.ts
@@ -0,0 +1,335 @@
+import {
+ ChainExecutionResult,
+ ChainStep,
+ ChainStepResult,
+ ChainTemplate,
+ SavedKey,
+} from '../../../shared/types';
+import { ChainService } from '../../../shared/services/types';
+import {
+ STORAGE_KEYS,
+ isRecord,
+ readCollection,
+ reviveDate,
+ writeCollection,
+} from './storage/browserStorageCommon';
+import { loadKeys } from './storage/browserKeyService';
+import { normalizeAlgorithm, rsaDecrypt, rsaEncrypt } from './crypto/browserCryptoCore';
+
+const reviveChainTemplate = (template: unknown): ChainTemplate => {
+ if (!isRecord(template)) {
+ throw new Error('Invalid chain template record');
+ }
+ const record = template as Record;
+ const base = template as unknown as ChainTemplate;
+ return {
+ ...base,
+ created: reviveDate(record.created),
+ lastUsed: record.lastUsed ? reviveDate(record.lastUsed) : undefined,
+ };
+};
+
+const chainTemplatesKey = STORAGE_KEYS.chainTemplates;
+
+const listTemplates = async (): Promise =>
+ readCollection(chainTemplatesKey, reviveChainTemplate);
+
+const simpleBase64Encode = (value: string): string => {
+ if (typeof Buffer !== 'undefined') {
+ return Buffer.from(value, 'utf8').toString('base64');
+ }
+ if (typeof btoa === 'function' && typeof TextEncoder !== 'undefined') {
+ const bytes = new TextEncoder().encode(value);
+ let binary = '';
+ bytes.forEach(byte => {
+ binary += String.fromCharCode(byte);
+ });
+ return btoa(binary);
+ }
+ throw new Error('Base64 인코딩을 지원하지 않는 환경입니다.');
+};
+
+const simpleBase64Decode = (value: string): string => {
+ if (typeof Buffer !== 'undefined') {
+ return Buffer.from(value, 'base64').toString('utf8');
+ }
+ if (typeof atob === 'function' && typeof TextDecoder !== 'undefined') {
+ const binary = atob(value);
+ const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+ }
+ throw new Error('Base64 디코딩을 지원하지 않는 환경입니다.');
+};
+
+const generateId = (): string => {
+ const cryptoObj = typeof globalThis !== 'undefined' ? (globalThis.crypto as Crypto | undefined) : undefined;
+ if (cryptoObj?.randomUUID) {
+ return cryptoObj.randomUUID();
+ }
+ return Math.random().toString(36).slice(2, 10);
+};
+
+const executeHttpParse = (input: string, step: ChainStep): string => {
+ const url = new URL(input.trim());
+ const pathTemplate = step.params?.pathTemplate || '';
+ const queryTemplate = step.params?.queryTemplate || '';
+ const outputType = step.params?.outputType || 'full';
+ const outputField = step.params?.outputField;
+ const outputParam = step.params?.outputParam;
+
+ const pathParams: Record = {};
+ if (pathTemplate) {
+ const templateParts = pathTemplate.split('/').filter(Boolean);
+ const urlParts = url.pathname.split('/').filter(Boolean);
+
+ templateParts.forEach((templatePart, index) => {
+ if (templatePart.startsWith(':')) {
+ const paramName = templatePart.substring(1);
+ if (urlParts[index]) {
+ pathParams[paramName] = decodeURIComponent(urlParts[index]);
+ }
+ } else if (templatePart.startsWith('{') && templatePart.endsWith('}')) {
+ const paramName = templatePart.slice(1, -1);
+ if (urlParts[index]) {
+ pathParams[paramName] = decodeURIComponent(urlParts[index]);
+ }
+ }
+ });
+ }
+
+ const queryParams: Record = {};
+ if (queryTemplate) {
+ try {
+ const queryKeys = JSON.parse(queryTemplate);
+ if (Array.isArray(queryKeys)) {
+ const urlParams = new URLSearchParams(url.search);
+ queryKeys.forEach(key => {
+ const value = urlParams.get(key);
+ if (value !== null) {
+ queryParams[key] = value;
+ }
+ });
+ }
+ } catch {
+ // ignore JSON parse errors and return empty query params
+ }
+ } else {
+ const urlParams = new URLSearchParams(url.search);
+ urlParams.forEach((value, key) => {
+ queryParams[key] = value;
+ });
+ }
+
+ const result = {
+ protocol: url.protocol,
+ host: url.host,
+ pathname: url.pathname,
+ search: url.search,
+ hash: url.hash,
+ pathParams,
+ queryParams,
+ };
+
+ if (outputType === 'field' && outputField) {
+ return (result as any)[outputField] ?? '';
+ }
+
+ if (outputType === 'param' && outputParam) {
+ return result.queryParams[outputParam] ?? '';
+ }
+
+ return JSON.stringify(result, null, 2);
+};
+
+const executeHttpBuild = (input: string, step: ChainStep): string => {
+ let baseUrl = step.params?.baseUrl || '';
+ if (!baseUrl) {
+ baseUrl = input.trim();
+ }
+
+ let fullUrl = baseUrl;
+
+ const pathParams = (step.params?.pathParams ?? {}) as Record;
+ const queryParams = (step.params?.queryParams ?? {}) as Record;
+ const pathTemplate = step.params?.pathTemplate || '';
+ const queryTemplate = step.params?.queryTemplate || '';
+
+ if (pathTemplate) {
+ let pathPart = pathTemplate;
+ Object.entries(pathParams).forEach(([key, value]) => {
+ pathPart = pathPart
+ .replace(`:${key}`, encodeURIComponent(value))
+ .replace(`{${key}}`, encodeURIComponent(value));
+ });
+
+ if (!fullUrl.endsWith('/') && !pathPart.startsWith('/')) {
+ fullUrl += '/';
+ }
+ if (fullUrl.endsWith('/') && pathPart.startsWith('/')) {
+ pathPart = pathPart.substring(1);
+ }
+
+ fullUrl += pathPart;
+ }
+
+ const params = new URLSearchParams();
+ if (queryTemplate) {
+ try {
+ const expectedParams = JSON.parse(queryTemplate);
+ if (Array.isArray(expectedParams)) {
+ expectedParams.forEach((key: string) => {
+ if (queryParams[key]) {
+ params.set(key, queryParams[key]);
+ }
+ });
+ }
+ } catch {
+ // ignore template parsing errors
+ }
+ } else {
+ Object.entries(queryParams).forEach(([key, value]) => {
+ params.set(key, value);
+ });
+ }
+
+ const queryString = params.toString();
+ if (queryString) {
+ fullUrl += fullUrl.includes('?') ? '&' : '?';
+ fullUrl += queryString;
+ }
+
+ return fullUrl;
+};
+
+interface ChainExecutionContext {
+ keysById: Map;
+}
+
+const resolveChainKey = (keyId: string | undefined, context: ChainExecutionContext): SavedKey => {
+ if (!keyId) {
+ throw new Error('RSA 키가 선택되지 않았습니다.');
+ }
+ const key = context.keysById.get(keyId);
+ if (!key) {
+ throw new Error('선택한 RSA 키를 찾을 수 없습니다. 키가 삭제되었거나 이름이 변경되었을 수 있습니다.');
+ }
+ return key;
+};
+
+const runChainStep = async (step: ChainStep, input: string, context: ChainExecutionContext): Promise => {
+ switch (step.type) {
+ case 'url-encode':
+ return encodeURIComponent(input);
+ case 'url-decode':
+ return decodeURIComponent(input);
+ case 'base64-encode':
+ return simpleBase64Encode(input);
+ case 'base64-decode':
+ return simpleBase64Decode(input);
+ case 'http-parse':
+ return executeHttpParse(input, step);
+ case 'http-build':
+ return executeHttpBuild(input, step);
+ case 'rsa-encrypt': {
+ const key = resolveChainKey(step.params?.keyId, context);
+ const algorithm = normalizeAlgorithm(step.params?.algorithm ?? key.preferredAlgorithm);
+ const { data } = await rsaEncrypt(input, key.publicKey, algorithm);
+ return data;
+ }
+ case 'rsa-decrypt': {
+ const key = resolveChainKey(step.params?.keyId, context);
+ const algorithm = normalizeAlgorithm(step.params?.algorithm ?? key.preferredAlgorithm);
+ return rsaDecrypt(input, key.privateKey, algorithm);
+ }
+ default:
+ throw new Error(`지원되지 않는 스텝 유형입니다: ${step.type}`);
+ }
+};
+
+export const browserChainService: ChainService = {
+ async listTemplates() {
+ return listTemplates();
+ },
+ async saveTemplate(template) {
+ const templates = await listTemplates();
+ const existingIndex = templates.findIndex(t => t.id === template.id);
+ if (existingIndex >= 0) {
+ templates[existingIndex] = template;
+ } else {
+ templates.push(template);
+ }
+ writeCollection(chainTemplatesKey, templates);
+ },
+ async updateTemplate(template) {
+ const templates = await listTemplates();
+ const index = templates.findIndex(t => t.id === template.id);
+ if (index === -1) {
+ throw new Error(`템플릿을 찾을 수 없습니다: ${template.id}`);
+ }
+ templates[index] = template;
+ writeCollection(chainTemplatesKey, templates);
+ },
+ async removeTemplate(templateId) {
+ const templates = await listTemplates();
+ writeCollection(
+ chainTemplatesKey,
+ templates.filter(t => t.id !== templateId),
+ );
+ },
+ async executeChain(steps, inputText, templateId, templateName) {
+ const results: ChainStepResult[] = [];
+ let currentOutput = inputText;
+ const enabledSteps = steps.filter(step => step.enabled);
+ const keys = await loadKeys();
+ const context: ChainExecutionContext = {
+ keysById: new Map(keys.map(key => [key.id, key])),
+ };
+
+ for (const step of enabledSteps) {
+ const start = Date.now();
+ const result: ChainStepResult = {
+ stepId: step.id,
+ stepType: step.type,
+ input: currentOutput,
+ output: '',
+ success: false,
+ duration: 0,
+ };
+
+ try {
+ const output = await runChainStep(step, currentOutput, context);
+ result.output = output;
+ result.success = true;
+ currentOutput = output;
+ } catch (error) {
+ result.error = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
+ result.success = false;
+ result.output = currentOutput;
+ } finally {
+ result.duration = Date.now() - start;
+ }
+
+ results.push(result);
+
+ if (!result.success) {
+ break;
+ }
+ }
+
+ const success = results.every(step => step.success);
+
+ const executionResult: ChainExecutionResult = {
+ id: generateId(),
+ templateId,
+ templateName,
+ success,
+ steps: results,
+ finalOutput: currentOutput,
+ totalDuration: results.reduce((total, step) => total + step.duration, 0),
+ timestamp: new Date(),
+ inputText,
+ };
+
+ return executionResult;
+ },
+};
diff --git a/src/renderer/services/browser/browserCryptoService.ts b/src/renderer/services/browser/browserCryptoService.ts
new file mode 100644
index 0000000..191f525
--- /dev/null
+++ b/src/renderer/services/browser/browserCryptoService.ts
@@ -0,0 +1,40 @@
+import { CryptoService } from '../../../shared/services/types';
+import { rsaDecrypt, rsaEncrypt, rsaGenerateKeyPair, normalizeAlgorithm } from './crypto/browserCryptoCore';
+
+export const browserCryptoService: CryptoService = {
+ async encrypt(text, publicKey, algorithm) {
+ const normalized = normalizeAlgorithm(algorithm);
+ try {
+ const result = await rsaEncrypt(text, publicKey, normalized);
+ return {
+ data: result.data,
+ algorithm: normalized,
+ keySize: result.keySize,
+ timestamp: new Date(),
+ };
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'OperationError') {
+ throw new Error('암호화 실패: 입력 데이터가 너무 길거나 알고리즘이 지원되지 않습니다. 텍스트 길이와 키 크기를 확인해주세요.');
+ }
+ throw new Error(`암호화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ }
+ },
+ async decrypt(encryptedText, privateKey, algorithm) {
+ const normalized = normalizeAlgorithm(algorithm);
+ try {
+ return await rsaDecrypt(encryptedText, privateKey, normalized);
+ } catch (error) {
+ throw new Error(error instanceof Error ? error.message : '복호화 실패: 알 수 없는 오류가 발생했습니다.');
+ }
+ },
+ async generateKeyPair(keySize) {
+ if (!Number.isFinite(keySize) || keySize < 1024) {
+ throw new Error('유효한 키 크기를 입력해주세요. (1024비트 이상)');
+ }
+ try {
+ return await rsaGenerateKeyPair(keySize);
+ } catch (error) {
+ throw new Error(`키 생성 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ }
+ },
+};
diff --git a/src/renderer/services/browser/browserHttpTemplateService.ts b/src/renderer/services/browser/browserHttpTemplateService.ts
new file mode 100644
index 0000000..5aa2508
--- /dev/null
+++ b/src/renderer/services/browser/browserHttpTemplateService.ts
@@ -0,0 +1,111 @@
+import { HttpTemplate } from '../../../shared/types';
+import { HttpTemplateService } from '../../../shared/services/types';
+import {
+ STORAGE_KEYS,
+ isRecord,
+ readCollection,
+ reviveDate,
+ writeCollection,
+} from './storage/browserStorageCommon';
+
+const reviveHttpTemplate = (template: unknown): HttpTemplate => {
+ if (!isRecord(template)) {
+ throw new Error('Invalid HTTP template record');
+ }
+ const record = template as Record;
+ const base = template as unknown as HttpTemplate;
+ return {
+ ...base,
+ created: reviveDate(record.created),
+ lastUsed: record.lastUsed ? reviveDate(record.lastUsed) : undefined,
+ };
+};
+
+const loadHttpTemplates = async (): Promise =>
+ readCollection(STORAGE_KEYS.httpTemplates, reviveHttpTemplate);
+
+export const browserHttpTemplateService: HttpTemplateService = {
+ async list() {
+ return loadHttpTemplates();
+ },
+ async save(template) {
+ const templates = await loadHttpTemplates();
+ const index = templates.findIndex(t => t.id === template.id);
+ if (index >= 0) {
+ throw new Error('동일한 ID의 템플릿이 이미 존재합니다.');
+ }
+ templates.push(template);
+ writeCollection(STORAGE_KEYS.httpTemplates, templates);
+ },
+ async update(template) {
+ const templates = await loadHttpTemplates();
+ const index = templates.findIndex(t => t.id === template.id);
+ if (index === -1) {
+ throw new Error('템플릿을 찾을 수 없습니다.');
+ }
+ templates[index] = template;
+ writeCollection(STORAGE_KEYS.httpTemplates, templates);
+ },
+ async remove(templateId) {
+ const templates = await loadHttpTemplates();
+ writeCollection(
+ STORAGE_KEYS.httpTemplates,
+ templates.filter(t => t.id !== templateId),
+ );
+ },
+ async useTemplate(templateId, pathParams, queryParams) {
+ const templates = await this.list();
+ const template = templates.find(t => t.id === templateId);
+ if (!template) {
+ throw new Error('선택한 템플릿을 찾을 수 없습니다.');
+ }
+
+ let fullUrl = template.baseUrl;
+
+ if (template.pathTemplate) {
+ let pathPart = template.pathTemplate;
+ Object.entries(pathParams).forEach(([key, value]) => {
+ pathPart = pathPart
+ .replace(`:${key}`, encodeURIComponent(value))
+ .replace(`{${key}}`, encodeURIComponent(value));
+ });
+
+ if (!fullUrl.endsWith('/') && !pathPart.startsWith('/')) {
+ fullUrl += '/';
+ }
+ if (fullUrl.endsWith('/') && pathPart.startsWith('/')) {
+ pathPart = pathPart.substring(1);
+ }
+
+ fullUrl += pathPart;
+ }
+
+ const params = new URLSearchParams();
+ if (template.queryTemplate) {
+ try {
+ const expectedParams = JSON.parse(template.queryTemplate);
+ if (Array.isArray(expectedParams)) {
+ expectedParams.forEach((key: string) => {
+ if (queryParams[key]) {
+ params.set(key, queryParams[key]);
+ }
+ });
+ }
+ } catch {
+ // ignore parsing errors
+ }
+ } else {
+ Object.entries(queryParams).forEach(([key, value]) => {
+ params.set(key, value);
+ });
+ }
+
+ const queryString = params.toString();
+ if (queryString) {
+ fullUrl += fullUrl.includes('?') ? '&' : '?';
+ fullUrl += queryString;
+ }
+
+ return fullUrl;
+ },
+};
diff --git a/src/renderer/services/browser/crypto/browserCryptoCore.ts b/src/renderer/services/browser/crypto/browserCryptoCore.ts
new file mode 100644
index 0000000..3a73dce
--- /dev/null
+++ b/src/renderer/services/browser/crypto/browserCryptoCore.ts
@@ -0,0 +1,217 @@
+import { RSAKeyPair } from '../../../../shared/types';
+
+export type SupportedRSAAlgorithm = 'RSA-OAEP' | 'RSA-PKCS1';
+
+const ensureSubtleCrypto = (): SubtleCrypto => {
+ const cryptoObj = (typeof globalThis !== 'undefined' ? globalThis.crypto : undefined) as Crypto | undefined;
+ const subtle = cryptoObj?.subtle ?? (cryptoObj ? (cryptoObj as any).webkitSubtle : undefined);
+ if (!subtle) {
+ throw new Error('이 브라우저는 Web Crypto API를 지원하지 않습니다. 최신 버전의 브라우저를 사용해주세요.');
+ }
+ return subtle;
+};
+
+const bufferToBase64 = (buffer: ArrayBuffer): string => {
+ if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ bytes.forEach(byte => {
+ binary += String.fromCharCode(byte);
+ });
+ return window.btoa(binary);
+ }
+ return Buffer.from(buffer).toString('base64');
+};
+
+const base64ToBuffer = (base64: string): ArrayBuffer => {
+ const normalized = base64.replace(/\s+/g, '');
+ if (!normalized) {
+ return new ArrayBuffer(0);
+ }
+ if (typeof window !== 'undefined' && typeof window.atob === 'function') {
+ const binary = window.atob(normalized);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+ return Buffer.from(normalized, 'base64').buffer;
+};
+
+const derToPem = (buffer: ArrayBuffer, label: 'PUBLIC KEY' | 'PRIVATE KEY'): string => {
+ const base64 = bufferToBase64(buffer);
+ const chunks = base64.match(/.{1,64}/g) || [];
+ return `-----BEGIN ${label}-----\n${chunks.join('\n')}\n-----END ${label}-----`;
+};
+
+const pemToDer = (pem: string): ArrayBuffer => {
+ const base64 = pem
+ .replace(/-----BEGIN [^-]+-----/g, '')
+ .replace(/-----END [^-]+-----/g, '')
+ .replace(/\s+/g, '');
+ return base64ToBuffer(base64);
+};
+
+const textEncoder = new TextEncoder();
+const textDecoder = new TextDecoder();
+
+const stringToBuffer = (value: string): ArrayBuffer => textEncoder.encode(value).buffer;
+const bufferToString = (buffer: ArrayBuffer): string => textDecoder.decode(buffer);
+
+const validateBase64 = (data: string): { isValid: boolean; error?: string } => {
+ try {
+ const cleanData = data.trim().replace(/\s/g, '');
+
+ if (!cleanData) {
+ return { isValid: false, error: 'Base64 데이터가 비어있습니다' };
+ }
+
+ const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
+ if (!base64Regex.test(cleanData)) {
+ return { isValid: false, error: 'Base64 형식이 올바르지 않습니다' };
+ }
+
+ if (cleanData.length % 4 !== 0) {
+ return { isValid: false, error: 'Base64 데이터 길이가 올바르지 않습니다 (4의 배수가 아닙니다)' };
+ }
+
+ const paddingIndex = cleanData.indexOf('=');
+ if (paddingIndex !== -1) {
+ const paddingPart = cleanData.substring(paddingIndex);
+ if (paddingPart !== '=' && paddingPart !== '==') {
+ return { isValid: false, error: 'Base64 패딩이 올바르지 않습니다' };
+ }
+ }
+
+ return { isValid: true };
+ } catch {
+ return { isValid: false, error: 'Base64 데이터 검증 중 오류가 발생했습니다' };
+ }
+};
+
+const getImportParams = (algorithm: SupportedRSAAlgorithm): AlgorithmIdentifier => {
+ if (algorithm === 'RSA-PKCS1') {
+ return { name: 'RSAES-PKCS1-v1_5' } as AlgorithmIdentifier;
+ }
+ return {
+ name: 'RSA-OAEP',
+ hash: 'SHA-1',
+ } as RsaHashedImportParams;
+};
+
+const extractModulusLength = (key: CryptoKey): number => {
+ const algorithm = key.algorithm as unknown as { modulusLength?: number };
+ const value = algorithm?.modulusLength;
+ return typeof value === 'number' ? value : 0;
+};
+
+const importPublicKey = async (
+ pem: string,
+ algorithm: SupportedRSAAlgorithm,
+): Promise<{ key: CryptoKey; modulusLength: number }> => {
+ const subtle = ensureSubtleCrypto();
+ const keyData = pemToDer(pem);
+ const cryptoKey = await subtle.importKey(
+ 'spki',
+ keyData,
+ getImportParams(algorithm),
+ false,
+ ['encrypt']
+ );
+ return {
+ key: cryptoKey,
+ modulusLength: extractModulusLength(cryptoKey),
+ };
+};
+
+const importPrivateKey = async (pem: string, algorithm: SupportedRSAAlgorithm): Promise => {
+ const subtle = ensureSubtleCrypto();
+ const keyData = pemToDer(pem);
+ return subtle.importKey(
+ 'pkcs8',
+ keyData,
+ getImportParams(algorithm),
+ false,
+ ['decrypt']
+ );
+};
+
+export const rsaEncrypt = async (
+ text: string,
+ publicKeyPem: string,
+ algorithm: SupportedRSAAlgorithm,
+): Promise<{ data: string; keySize: number }> => {
+ const { key, modulusLength } = await importPublicKey(publicKeyPem, algorithm);
+ const subtle = ensureSubtleCrypto();
+ const dataBuffer = stringToBuffer(text);
+ const encrypted = await subtle.encrypt(
+ getImportParams(algorithm),
+ key,
+ dataBuffer,
+ );
+ return {
+ data: bufferToBase64(encrypted),
+ keySize: modulusLength,
+ };
+};
+
+export const rsaDecrypt = async (
+ encryptedText: string,
+ privateKeyPem: string,
+ algorithm: SupportedRSAAlgorithm,
+): Promise => {
+ const validation = validateBase64(encryptedText);
+ if (!validation.isValid) {
+ throw new Error(`입력 데이터 검증 실패: ${validation.error}`);
+ }
+
+ const subtle = ensureSubtleCrypto();
+ const key = await importPrivateKey(privateKeyPem, algorithm);
+ try {
+ const decrypted = await subtle.decrypt(
+ getImportParams(algorithm),
+ key,
+ base64ToBuffer(encryptedText),
+ );
+ return bufferToString(decrypted);
+ } catch (error) {
+ if (error instanceof DOMException) {
+ if (error.name === 'DataError') {
+ throw new Error('Base64 디코딩 실패: 암호화 데이터가 손상되었을 수 있습니다.');
+ }
+ if (error.name === 'OperationError') {
+ throw new Error('복호화 실패: 키 또는 알고리즘이 올바른지 확인해주세요.');
+ }
+ }
+ throw new Error(`복호화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ }
+};
+
+export const rsaGenerateKeyPair = async (keySize: number): Promise => {
+ const subtle = ensureSubtleCrypto();
+ const keyPair = await subtle.generateKey(
+ {
+ name: 'RSA-OAEP',
+ modulusLength: keySize,
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
+ hash: 'SHA-1',
+ },
+ true,
+ ['encrypt', 'decrypt']
+ );
+
+ const publicKeyBuffer = await subtle.exportKey('spki', keyPair.publicKey);
+ const privateKeyBuffer = await subtle.exportKey('pkcs8', keyPair.privateKey);
+
+ return {
+ publicKey: derToPem(publicKeyBuffer, 'PUBLIC KEY'),
+ privateKey: derToPem(privateKeyBuffer, 'PRIVATE KEY'),
+ keySize,
+ created: new Date(),
+ };
+};
+
+export const normalizeAlgorithm = (algorithm?: string): SupportedRSAAlgorithm => (
+ algorithm === 'RSA-PKCS1' ? 'RSA-PKCS1' : 'RSA-OAEP'
+);
diff --git a/src/renderer/services/browser/storage/browserHistoryService.ts b/src/renderer/services/browser/storage/browserHistoryService.ts
new file mode 100644
index 0000000..dafb165
--- /dev/null
+++ b/src/renderer/services/browser/storage/browserHistoryService.ts
@@ -0,0 +1,58 @@
+import { HistoryFilter, HistoryItem } from '../../../../shared/types';
+import { HistoryService } from '../../../../shared/services/types';
+import {
+ STORAGE_KEYS,
+ isRecord,
+ readCollection,
+ reviveDate,
+ writeCollection,
+} from './browserStorageCommon';
+
+const reviveHistoryItem = (raw: unknown): HistoryItem => {
+ if (!isRecord(raw)) {
+ throw new Error('Invalid history record');
+ }
+ const record = raw as Record;
+ const base = raw as unknown as HistoryItem;
+ return {
+ ...base,
+ timestamp: reviveDate(record.timestamp),
+ };
+};
+
+const matchesFilter = (item: HistoryItem, filter?: HistoryFilter): boolean => {
+ if (!filter) return true;
+ if (filter.type && item.type !== filter.type) return false;
+ if (filter.keyId && item.keyId !== filter.keyId) return false;
+ if (filter.algorithm && item.algorithm !== filter.algorithm) return false;
+ if (filter.success !== undefined && item.success !== filter.success) return false;
+ if (filter.dateFrom && item.timestamp < filter.dateFrom) return false;
+ if (filter.dateTo && item.timestamp > filter.dateTo) return false;
+ if (filter.chainId && item.chainId !== filter.chainId) return false;
+ return true;
+};
+
+export const loadHistory = async (): Promise =>
+ readCollection(STORAGE_KEYS.history, reviveHistoryItem);
+
+export const browserHistoryService: HistoryService = {
+ async list(filter) {
+ const history = await loadHistory();
+ return history.filter(item => matchesFilter(item, filter));
+ },
+ async save(item) {
+ const history = await loadHistory();
+ history.unshift(item);
+ writeCollection(STORAGE_KEYS.history, history);
+ },
+ async remove(historyId) {
+ const history = await loadHistory();
+ writeCollection(
+ STORAGE_KEYS.history,
+ history.filter(item => item.id !== historyId),
+ );
+ },
+ async clear() {
+ writeCollection(STORAGE_KEYS.history, []);
+ },
+};
diff --git a/src/renderer/services/browser/storage/browserKeyService.ts b/src/renderer/services/browser/storage/browserKeyService.ts
new file mode 100644
index 0000000..81874af
--- /dev/null
+++ b/src/renderer/services/browser/storage/browserKeyService.ts
@@ -0,0 +1,45 @@
+import { SavedKey } from '../../../../shared/types';
+import { KeyService } from '../../../../shared/services/types';
+import {
+ isRecord,
+ readCollection,
+ reviveDate,
+ writeCollection,
+ STORAGE_KEYS,
+} from './browserStorageCommon';
+
+const reviveSavedKey = (raw: unknown): SavedKey => {
+ if (!isRecord(raw)) {
+ throw new Error('Invalid saved key record');
+ }
+ const record = raw as Record;
+ const base = raw as unknown as SavedKey;
+ return {
+ ...base,
+ created: reviveDate(record.created),
+ };
+};
+
+export const loadKeys = async (): Promise =>
+ readCollection(STORAGE_KEYS.keys, reviveSavedKey);
+
+export const browserKeyService: KeyService = {
+ async list() {
+ return loadKeys();
+ },
+ async save(key) {
+ const keys = await loadKeys();
+ const index = keys.findIndex(k => k.id === key.id);
+ if (index >= 0) {
+ keys[index] = key;
+ } else {
+ keys.push(key);
+ }
+ writeCollection(STORAGE_KEYS.keys, keys);
+ },
+ async remove(keyId) {
+ const keys = await loadKeys();
+ const filtered = keys.filter(key => key.id !== keyId);
+ writeCollection(STORAGE_KEYS.keys, filtered);
+ },
+};
diff --git a/src/renderer/services/browser/storage/browserStorageCommon.ts b/src/renderer/services/browser/storage/browserStorageCommon.ts
new file mode 100644
index 0000000..a7f7fd7
--- /dev/null
+++ b/src/renderer/services/browser/storage/browserStorageCommon.ts
@@ -0,0 +1,103 @@
+// Browser-specific storage utilities shared across web demo services.
+
+type StorageLike = {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+};
+
+const memoryStorage = (() => {
+ const store = new Map();
+ const storage: StorageLike = {
+ getItem: key => (store.has(key) ? store.get(key)! : null),
+ setItem: (key, value) => {
+ store.set(key, value);
+ },
+ removeItem: key => {
+ store.delete(key);
+ },
+ };
+ return storage;
+})();
+
+const getStorage = (): StorageLike => {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ return window.localStorage;
+ }
+ return memoryStorage;
+};
+
+export const STORAGE_KEYS = {
+ keys: 'risa.keys',
+ history: 'risa.history',
+ chainTemplates: 'risa.chainTemplates',
+ httpTemplates: 'risa.httpTemplates',
+} as const;
+
+export const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+const sanitizeItems = (items: T[]): T[] =>
+ items.map(item => {
+ if (isRecord(item)) {
+ const clone = { ...item } as Record;
+ delete clone.expiresAt;
+ return clone as unknown as T;
+ }
+ return item;
+ });
+
+export const writeCollection = (key: string, value: T[]): void => {
+ const sanitized = sanitizeItems(value);
+ getStorage().setItem(key, JSON.stringify(sanitized));
+};
+
+export const readCollection = (key: string, revive?: (raw: unknown) => T): T[] => {
+ try {
+ const raw = getStorage().getItem(key);
+ if (!raw) return [];
+
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+
+ const result: T[] = [];
+ let requiresRewrite = false;
+
+ parsed.forEach(entry => {
+ let rawValue = entry;
+ if (isRecord(entry) && 'value' in entry) {
+ rawValue = (entry as Record).value;
+ requiresRewrite = true;
+ }
+
+ try {
+ const value = revive ? revive(rawValue) : (rawValue as T);
+ if (isRecord(value)) {
+ delete (value as Record).expiresAt;
+ }
+ result.push(value);
+ } catch {
+ requiresRewrite = true;
+ }
+ });
+
+ if (requiresRewrite) {
+ writeCollection(key, result);
+ }
+
+ return result;
+ } catch {
+ return [];
+ }
+};
+
+export const reviveDate = (value: unknown, fallback = new Date()): Date => {
+ if (value instanceof Date) {
+ return value;
+ }
+ if (typeof value === 'string' || typeof value === 'number') {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? fallback : date;
+ }
+ return fallback;
+};
diff --git a/src/renderer/services/browserServices.ts b/src/renderer/services/browserServices.ts
index c39e21c..1428836 100644
--- a/src/renderer/services/browserServices.ts
+++ b/src/renderer/services/browserServices.ts
@@ -1,531 +1,16 @@
-import {
- ChainExecutionResult,
- ChainStep,
- ChainStepResult,
- ChainTemplate,
- HistoryFilter,
- HistoryItem,
- HttpTemplate,
- SavedKey,
-} from '../../shared/types';
-import {
- ChainService,
- CryptoService,
- HistoryService,
- HttpTemplateService,
- KeyService,
- PlatformServices,
-} from '../../shared/services/types';
-
-type StorageLike = {
- getItem(key: string): string | null;
- setItem(key: string, value: string): void;
- removeItem(key: string): void;
-};
-
-const memoryStorage = (() => {
- const store = new Map();
- const storage: StorageLike = {
- getItem: (key) => (store.has(key) ? store.get(key)! : null),
- setItem: (key, value) => {
- store.set(key, value);
- },
- removeItem: (key) => {
- store.delete(key);
- },
- };
- return storage;
-})();
-
-const getStorage = (): StorageLike => {
- if (typeof window !== 'undefined' && window.localStorage) {
- return window.localStorage;
- }
- return memoryStorage;
-};
-
-const STORAGE_KEYS = {
- keys: 'risa.keys',
- history: 'risa.history',
- chainTemplates: 'risa.chainTemplates',
- httpTemplates: 'risa.httpTemplates',
-} as const;
-
-const readCollection = (key: string, revive?: (item: any) => T): T[] => {
- try {
- const raw = getStorage().getItem(key);
- if (!raw) return [];
- const parsed: unknown = JSON.parse(raw);
- if (!Array.isArray(parsed)) return [];
- return revive ? parsed.map(item => revive(item)) : (parsed as T[]);
- } catch {
- return [];
- }
-};
-
-const writeCollection = (key: string, value: T[]): void => {
- getStorage().setItem(key, JSON.stringify(value));
-};
-
-const reviveDate = (value: any, fallback = new Date()): Date => {
- if (!value) return fallback;
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? fallback : date;
-};
-
-const loadKeys = async (): Promise =>
- readCollection(STORAGE_KEYS.keys, (item) => ({
- ...item,
- created: reviveDate(item.created),
- }));
-
-const keyService: KeyService = {
- async list() {
- return loadKeys();
- },
- async save(key) {
- const keys = await loadKeys();
- const index = keys.findIndex(k => k.id === key.id);
- if (index >= 0) {
- keys[index] = key;
- } else {
- keys.push(key);
- }
- writeCollection(STORAGE_KEYS.keys, keys);
- },
- async remove(keyId) {
- const keys = await loadKeys();
- const filtered = keys.filter(key => key.id !== keyId);
- writeCollection(STORAGE_KEYS.keys, filtered);
- },
-};
-
-const matchesFilter = (item: HistoryItem, filter?: HistoryFilter): boolean => {
- if (!filter) return true;
- if (filter.type && item.type !== filter.type) return false;
- if (filter.keyId && item.keyId !== filter.keyId) return false;
- if (filter.algorithm && item.algorithm !== filter.algorithm) return false;
- if (filter.success !== undefined && item.success !== filter.success) return false;
- if (filter.dateFrom && item.timestamp < filter.dateFrom) return false;
- if (filter.dateTo && item.timestamp > filter.dateTo) return false;
- if (filter.chainId && item.chainId !== filter.chainId) return false;
- return true;
-};
-
-const loadHistory = async (): Promise =>
- readCollection(STORAGE_KEYS.history, (item) => ({
- ...item,
- timestamp: reviveDate(item.timestamp),
- }));
-
-const historyService: HistoryService = {
- async list(filter) {
- const history = await loadHistory();
- return history.filter(item => matchesFilter(item, filter));
- },
- async save(item) {
- const history = await loadHistory();
- history.unshift(item);
- writeCollection(STORAGE_KEYS.history, history);
- },
- async remove(historyId) {
- const history = await loadHistory();
- writeCollection(STORAGE_KEYS.history, history.filter(item => item.id !== historyId));
- },
- async clear() {
- writeCollection(STORAGE_KEYS.history, []);
- },
-};
-
-const reviveChainTemplate = (template: ChainTemplate): ChainTemplate => ({
- ...template,
- created: reviveDate(template.created),
- lastUsed: template.lastUsed ? reviveDate(template.lastUsed) : undefined,
-});
-
-const chainTemplatesKey = STORAGE_KEYS.chainTemplates;
-
-const listTemplates = async (): Promise => {
- return readCollection(chainTemplatesKey, (item) => reviveChainTemplate(item));
-};
-
-const simpleBase64Encode = (value: string): string => {
- if (typeof Buffer !== 'undefined') {
- return Buffer.from(value, 'utf8').toString('base64');
- }
- if (typeof btoa === 'function' && typeof TextEncoder !== 'undefined') {
- const bytes = new TextEncoder().encode(value);
- let binary = '';
- bytes.forEach(byte => {
- binary += String.fromCharCode(byte);
- });
- return btoa(binary);
- }
- throw new Error('Base64 인코딩을 지원하지 않는 환경입니다.');
-};
-
-const simpleBase64Decode = (value: string): string => {
- if (typeof Buffer !== 'undefined') {
- return Buffer.from(value, 'base64').toString('utf8');
- }
- if (typeof atob === 'function' && typeof TextDecoder !== 'undefined') {
- const binary = atob(value);
- const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
- return new TextDecoder().decode(bytes);
- }
- throw new Error('Base64 디코딩을 지원하지 않는 환경입니다.');
-};
-
-const generateId = (): string => {
- const cryptoObj = typeof globalThis !== 'undefined' ? (globalThis.crypto as Crypto | undefined) : undefined;
- if (cryptoObj?.randomUUID) {
- return cryptoObj.randomUUID();
- }
- return Math.random().toString(36).slice(2, 10);
-};
-
-const executeHttpParse = (input: string, step: ChainStep): string => {
- const url = new URL(input.trim());
- const pathTemplate = step.params?.pathTemplate || '';
- const queryTemplate = step.params?.queryTemplate || '';
- const outputType = step.params?.outputType || 'full';
- const outputField = step.params?.outputField;
- const outputParam = step.params?.outputParam;
-
- const pathParams: Record = {};
- if (pathTemplate) {
- const templateParts = pathTemplate.split('/').filter(Boolean);
- const urlParts = url.pathname.split('/').filter(Boolean);
-
- templateParts.forEach((templatePart, index) => {
- if (templatePart.startsWith(':')) {
- const paramName = templatePart.substring(1);
- if (urlParts[index]) {
- pathParams[paramName] = decodeURIComponent(urlParts[index]);
- }
- } else if (templatePart.startsWith('{') && templatePart.endsWith('}')) {
- const paramName = templatePart.slice(1, -1);
- if (urlParts[index]) {
- pathParams[paramName] = decodeURIComponent(urlParts[index]);
- }
- }
- });
- }
-
- const queryParams: Record = {};
- if (queryTemplate) {
- try {
- const queryKeys = JSON.parse(queryTemplate);
- if (Array.isArray(queryKeys)) {
- const urlParams = new URLSearchParams(url.search);
- queryKeys.forEach(key => {
- const value = urlParams.get(key);
- if (value !== null) {
- queryParams[key] = value;
- }
- });
- }
- } catch {
- // ignore JSON parse errors and return empty query params
- }
- } else {
- const urlParams = new URLSearchParams(url.search);
- urlParams.forEach((value, key) => {
- queryParams[key] = value;
- });
- }
-
- const result = {
- protocol: url.protocol,
- host: url.host,
- pathname: url.pathname,
- search: url.search,
- hash: url.hash,
- pathParams,
- queryParams,
- };
-
- if (outputType === 'field' && outputField) {
- return (result as any)[outputField] ?? '';
- }
-
- if (outputType === 'param' && outputParam) {
- return result.queryParams[outputParam] ?? '';
- }
-
- return JSON.stringify(result, null, 2);
-};
-
-const executeHttpBuild = (input: string, step: ChainStep): string => {
- let baseUrl = step.params?.baseUrl || '';
- if (!baseUrl) {
- baseUrl = input.trim();
- }
-
- let fullUrl = baseUrl;
-
- const pathParams = (step.params?.pathParams ?? {}) as Record;
- const queryParams = (step.params?.queryParams ?? {}) as Record;
- const pathTemplate = step.params?.pathTemplate || '';
- const queryTemplate = step.params?.queryTemplate || '';
-
- if (pathTemplate) {
- let pathPart = pathTemplate;
- Object.entries(pathParams).forEach(([key, value]) => {
- pathPart = pathPart
- .replace(`:${key}`, encodeURIComponent(value))
- .replace(`{${key}}`, encodeURIComponent(value));
- });
-
- if (!fullUrl.endsWith('/') && !pathPart.startsWith('/')) {
- fullUrl += '/';
- }
- if (fullUrl.endsWith('/') && pathPart.startsWith('/')) {
- pathPart = pathPart.substring(1);
- }
-
- fullUrl += pathPart;
- }
-
- const params = new URLSearchParams();
- if (queryTemplate) {
- try {
- const expectedParams = JSON.parse(queryTemplate);
- if (Array.isArray(expectedParams)) {
- expectedParams.forEach((key: string) => {
- if (queryParams[key]) {
- params.set(key, queryParams[key]);
- }
- });
- }
- } catch {
- // ignore template parsing errors
- }
- } else {
- Object.entries(queryParams).forEach(([key, value]) => {
- params.set(key, value);
- });
- }
-
- const queryString = params.toString();
- if (queryString) {
- fullUrl += fullUrl.includes('?') ? '&' : '?';
- fullUrl += queryString;
- }
-
- return fullUrl;
-};
-
-const runChainStep = (step: ChainStep, input: string): string => {
- switch (step.type) {
- case 'url-encode':
- return encodeURIComponent(input);
- case 'url-decode':
- return decodeURIComponent(input);
- case 'base64-encode':
- return simpleBase64Encode(input);
- case 'base64-decode':
- return simpleBase64Decode(input);
- case 'http-parse':
- return executeHttpParse(input, step);
- case 'http-build':
- return executeHttpBuild(input, step);
- case 'rsa-encrypt':
- case 'rsa-decrypt':
- throw new Error('웹 데모에서는 RSA 관련 체인 스텝을 지원하지 않습니다.');
- default:
- throw new Error(`지원되지 않는 스텝 유형입니다: ${step.type}`);
- }
-};
-
-const chainService: ChainService = {
- async listTemplates() {
- return listTemplates();
- },
- async saveTemplate(template) {
- const templates = await listTemplates();
- const existingIndex = templates.findIndex(t => t.id === template.id);
- if (existingIndex >= 0) {
- templates[existingIndex] = template;
- } else {
- templates.push(template);
- }
- writeCollection(chainTemplatesKey, templates);
- },
- async updateTemplate(template) {
- const templates = await listTemplates();
- const index = templates.findIndex(t => t.id === template.id);
- if (index === -1) {
- throw new Error(`템플릿을 찾을 수 없습니다: ${template.id}`);
- }
- templates[index] = template;
- writeCollection(chainTemplatesKey, templates);
- },
- async removeTemplate(templateId) {
- const templates = await listTemplates();
- writeCollection(chainTemplatesKey, templates.filter(t => t.id !== templateId));
- },
- async executeChain(steps, inputText, templateId, templateName) {
- const results: ChainStepResult[] = [];
- let currentOutput = inputText;
- const enabledSteps = steps.filter(step => step.enabled);
-
- for (const step of enabledSteps) {
- const start = Date.now();
- const result: ChainStepResult = {
- stepId: step.id,
- stepType: step.type,
- input: currentOutput,
- output: '',
- success: false,
- duration: 0,
- };
-
- try {
- const output = runChainStep(step, currentOutput);
- result.output = output;
- result.success = true;
- currentOutput = output;
- } catch (error) {
- result.error = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
- result.success = false;
- result.output = currentOutput;
- } finally {
- result.duration = Date.now() - start;
- }
-
- results.push(result);
-
- if (!result.success) {
- break;
- }
- }
-
- const success = results.every(step => step.success);
-
- const executionResult: ChainExecutionResult = {
- id: generateId(),
- templateId,
- templateName,
- success,
- steps: results,
- finalOutput: currentOutput,
- totalDuration: results.reduce((total, step) => total + step.duration, 0),
- timestamp: new Date(),
- inputText,
- };
-
- return executionResult;
- },
-};
-
-const loadHttpTemplates = async (): Promise =>
- readCollection(STORAGE_KEYS.httpTemplates, (item) => ({
- ...item,
- created: reviveDate(item.created),
- lastUsed: item.lastUsed ? reviveDate(item.lastUsed) : undefined,
- }));
-
-const httpTemplateService: HttpTemplateService = {
- async list() {
- return loadHttpTemplates();
- },
- async save(template) {
- const templates = await loadHttpTemplates();
- const index = templates.findIndex(t => t.id === template.id);
- if (index >= 0) {
- throw new Error('동일한 ID의 템플릿이 이미 존재합니다.');
- }
- templates.push(template);
- writeCollection(STORAGE_KEYS.httpTemplates, templates);
- },
- async update(template) {
- const templates = await loadHttpTemplates();
- const index = templates.findIndex(t => t.id === template.id);
- if (index === -1) {
- throw new Error('템플릿을 찾을 수 없습니다.');
- }
- templates[index] = template;
- writeCollection(STORAGE_KEYS.httpTemplates, templates);
- },
- async remove(templateId) {
- const templates = await loadHttpTemplates();
- writeCollection(STORAGE_KEYS.httpTemplates, templates.filter(t => t.id !== templateId));
- },
- async useTemplate(templateId, pathParams, queryParams) {
- const templates = await this.list();
- const template = templates.find(t => t.id === templateId);
- if (!template) {
- throw new Error('선택한 템플릿을 찾을 수 없습니다.');
- }
-
- let fullUrl = template.baseUrl;
-
- if (template.pathTemplate) {
- let pathPart = template.pathTemplate;
- Object.entries(pathParams).forEach(([key, value]) => {
- pathPart = pathPart
- .replace(`:${key}`, encodeURIComponent(value))
- .replace(`{${key}}`, encodeURIComponent(value));
- });
-
- if (!fullUrl.endsWith('/') && !pathPart.startsWith('/')) {
- fullUrl += '/';
- }
- if (fullUrl.endsWith('/') && pathPart.startsWith('/')) {
- pathPart = pathPart.substring(1);
- }
-
- fullUrl += pathPart;
- }
-
- const params = new URLSearchParams();
- if (template.queryTemplate) {
- try {
- const expectedParams = JSON.parse(template.queryTemplate);
- if (Array.isArray(expectedParams)) {
- expectedParams.forEach((key: string) => {
- if (queryParams[key]) {
- params.set(key, queryParams[key]);
- }
- });
- }
- } catch {
- // ignore parsing errors
- }
- } else {
- Object.entries(queryParams).forEach(([key, value]) => {
- params.set(key, value);
- });
- }
-
- const queryString = params.toString();
- if (queryString) {
- fullUrl += fullUrl.includes('?') ? '&' : '?';
- fullUrl += queryString;
- }
-
- return fullUrl;
- },
-};
-
-const cryptoService: CryptoService = {
- async encrypt() {
- throw new Error('웹 데모에서는 RSA 암호화를 지원하지 않습니다.');
- },
- async decrypt() {
- throw new Error('웹 데모에서는 RSA 복호화를 지원하지 않습니다.');
- },
- async generateKeyPair() {
- throw new Error('웹 데모에서는 RSA 키 생성을 지원하지 않습니다.');
- },
-};
-
+import { PlatformServices } from '../../shared/services/types';
+import { browserKeyService } from './browser/storage/browserKeyService';
+import { browserHistoryService } from './browser/storage/browserHistoryService';
+import { browserChainService } from './browser/browserChainService';
+import { browserHttpTemplateService } from './browser/browserHttpTemplateService';
+import { browserCryptoService } from './browser/browserCryptoService';
+
+// Browser/web demo service bundle used when running without the Electron bridge.
export const browserServices: PlatformServices = {
environment: 'web',
- key: keyService,
- history: historyService,
- chain: chainService,
- httpTemplate: httpTemplateService,
- crypto: cryptoService,
+ key: browserKeyService,
+ history: browserHistoryService,
+ chain: browserChainService,
+ httpTemplate: browserHttpTemplateService,
+ crypto: browserCryptoService,
};