From db619d7a0176e1ab7ad934a3f040840e10226c1f Mon Sep 17 00:00:00 2001 From: Kuingsmile <96409857+Kuingsmile@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:48:22 +0800 Subject: [PATCH] :sparkles: Feature(custom): support script market and share or download scripts --- src/main/events/rpc/index.ts | 2 + .../rpc/routes/scriptMarketplace/index.ts | 554 +++++++++++++++ src/main/utils/enum.ts | 10 + src/renderer/components/Editor.vue | 2 + src/renderer/i18n/locales/en.json | 49 ++ src/renderer/i18n/locales/zh-CN.json | 49 ++ src/renderer/i18n/locales/zh-TW.json | 49 ++ src/renderer/pages/ScriptPage.vue | 628 +++++++++++++++++- src/renderer/utils/configPaths.ts | 4 + src/renderer/utils/enum.ts | 10 + 10 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 src/main/events/rpc/routes/scriptMarketplace/index.ts diff --git a/src/main/events/rpc/index.ts b/src/main/events/rpc/index.ts index 28bb05bd..445f1909 100644 --- a/src/main/events/rpc/index.ts +++ b/src/main/events/rpc/index.ts @@ -6,6 +6,7 @@ import { galleryRouter } from '~/events/rpc/routes/gallery' import { manageRouter } from '~/events/rpc/routes/manage' import { picbedRouter } from '~/events/rpc/routes/picbed' import { pluginRouter } from '~/events/rpc/routes/plugin' +import { scriptMarketplaceRouter } from '~/events/rpc/routes/scriptMarketplace' import { settingRouter } from '~/events/rpc/routes/setting' import { systemRouter } from '~/events/rpc/routes/system' import { toolboxRouter } from '~/events/rpc/routes/toolbox' @@ -63,6 +64,7 @@ const routes = [ galleryRouter.routes(), picbedRouter.routes(), pluginRouter.routes(), + scriptMarketplaceRouter.routes(), settingRouter.routes(), systemRouter.routes(), toolboxRouter.routes(), diff --git a/src/main/events/rpc/routes/scriptMarketplace/index.ts b/src/main/events/rpc/routes/scriptMarketplace/index.ts new file mode 100644 index 00000000..0d7cd710 --- /dev/null +++ b/src/main/events/rpc/routes/scriptMarketplace/index.ts @@ -0,0 +1,554 @@ +import path from 'node:path' + +import { scriptsDir } from '@core/datastore/dirs' +import picgo from '@core/picgo' +import logger from '@core/picgo/logger' +import { shell } from 'electron' +import fs from 'fs-extra' + +import { RPCRouter } from '~/events/rpc/router' +import { IRPCActionType, IRPCType } from '~/utils/enum' + +const GITHUB_REPO_OWNER = 'Kuingsmile' +const GITHUB_REPO_NAME = 'piclist-ScriptsHub' +const GITHUB_API_BASE = 'https://api.github.com' +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com' +const GITHUB_CLIENT_ID = 'Ov23liLELZPZXhQnpadf' + +interface IScriptMeta { + name: string + author: string + description: string + version: string + fileName: string + category: string + downloadUrl: string + content?: string + createdAt?: string + updatedAt?: string +} + +interface IGitHubAuthState { + accessToken: string | null + username: string | null +} + +interface IDeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +interface IDeviceFlowState { + deviceCode: string | null + userCode: string | null + verificationUri: string | null + expiresAt: number | null + interval: number + pollingTimer: NodeJS.Timeout | null +} + +const authState: IGitHubAuthState = { + accessToken: null, + username: null, +} + +const deviceFlowState: IDeviceFlowState = { + deviceCode: null, + userCode: null, + verificationUri: null, + expiresAt: null, + interval: 5, + pollingTimer: null, +} + +async function loadAuthState() { + const savedToken = picgo.getConfig('scripts.githubToken') + const savedUsername = picgo.getConfig('scripts.githubUsername') + if (savedToken && savedUsername) { + authState.accessToken = savedToken + authState.username = savedUsername + } +} + +function saveAuthState() { + picgo.saveConfig({ + 'scripts.githubToken': authState.accessToken, + 'scripts.githubUsername': authState.username, + }) +} + +function clearAuthState() { + authState.accessToken = null + authState.username = null + picgo.saveConfig({ + 'scripts.githubToken': null, + 'scripts.githubUsername': null, + }) +} + +function parseScriptMeta(content: string, fileName: string, category: string): Partial { + const meta: Partial = { + fileName, + category, + } + + const metaRegex = /\/\*\*[\s\S]*?\*\// + const metaMatch = content.match(metaRegex) + + if (metaMatch) { + const metaBlock = metaMatch[0] + + const nameMatch = metaBlock.match(/@name\s+(.+)/) + if (nameMatch) meta.name = nameMatch[1].trim() + + const authorMatch = metaBlock.match(/@author\s+(.+)/) + if (authorMatch) meta.author = authorMatch[1].trim() + + const descMatch = metaBlock.match(/@description\s+(.+)/) + if (descMatch) meta.description = descMatch[1].trim() + + const versionMatch = metaBlock.match(/@version\s+(.+)/) + if (versionMatch) meta.version = versionMatch[1].trim() + } + + if (!meta.name) meta.name = fileName.replace('.js', '') + if (!meta.author) meta.author = 'Unknown' + if (!meta.description) meta.description = '' + if (!meta.version) meta.version = '1.0.0' + + return meta +} + +async function fetchScriptsList(): Promise { + const scripts: IScriptMeta[] = [] + + try { + const treeResponse = await fetch( + `${GITHUB_API_BASE}/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/git/trees/main?recursive=1`, + ) + + if (!treeResponse.ok) { + throw new Error(`Failed to fetch repository tree: ${treeResponse.statusText}`) + } + + const treeData = (await treeResponse.json()) as { tree: { path: string; type: string }[] } + + const jsFiles = treeData.tree.filter( + (item: { path: string; type: string }) => item.type === 'blob' && item.path.endsWith('.js'), + ) + + for (const file of jsFiles) { + const pathParts = file.path.split('/') + if (pathParts.length >= 2) { + const category = pathParts.slice(0, -1).join('.') + const fileName = pathParts[pathParts.length - 1] + + const contentResponse = await fetch( + `${GITHUB_RAW_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/main/${file.path}`, + ) + + if (contentResponse.ok) { + const content = await contentResponse.text() + const meta = parseScriptMeta(content, fileName, category) + + scripts.push({ + name: meta.name || fileName, + author: meta.author || 'Unknown', + description: meta.description || '', + version: meta.version || '1.0.0', + fileName, + category, + content, + downloadUrl: `${GITHUB_RAW_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/main/${file.path}`, + }) + } + } + } + } catch (error) { + logger.error(`Failed to fetch scripts list: ${error}`) + throw error + } + + return scripts +} + +async function downloadScript(scriptMeta: IScriptMeta): Promise { + try { + const response = await fetch(scriptMeta.downloadUrl) + + if (!response.ok) { + throw new Error(`Failed to download script: ${response.statusText}`) + } + + const content = await response.text() + + const categoryParts = scriptMeta.category.split('.') + const localPath = path.join(scriptsDir(), ...categoryParts, scriptMeta.fileName) + + fs.ensureDirSync(path.dirname(localPath)) + + fs.writeFileSync(localPath, content, 'utf-8') + + logger.info(`Downloaded script ${scriptMeta.name} to ${localPath}`) + return true + } catch (error) { + logger.error(`Failed to download script: ${error}`) + throw error + } +} + +async function initiateGitHubDeviceFlow(): Promise<{ + userCode: string + verificationUri: string + expiresIn: number +} | null> { + try { + if (deviceFlowState.pollingTimer) { + clearInterval(deviceFlowState.pollingTimer) + deviceFlowState.pollingTimer = null + } + + const response = await fetch('https://github.com/login/device/code', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + scope: 'public_repo', + }), + }) + + if (!response.ok) { + throw new Error(`Failed to get device code: ${response.statusText}`) + } + + const data = (await response.json()) as IDeviceCodeResponse + + deviceFlowState.deviceCode = data.device_code + deviceFlowState.userCode = data.user_code + deviceFlowState.verificationUri = data.verification_uri + deviceFlowState.expiresAt = Date.now() + data.expires_in * 1000 + deviceFlowState.interval = data.interval || 5 + + shell.openExternal(data.verification_uri) + + logger.info(`GitHub Device Flow initiated. User code: ${data.user_code}`) + + return { + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresIn: data.expires_in, + } + } catch (error) { + logger.error(`Failed to initiate GitHub Device Flow: ${error}`) + return null + } +} + +async function pollDeviceFlowAuthorization(): Promise<{ + success: boolean + username?: string + error?: string + nextInterval?: number +}> { + if (!deviceFlowState.deviceCode) { + return { success: false, error: 'No device code available. Please initiate login first.' } + } + + if (deviceFlowState.expiresAt && Date.now() > deviceFlowState.expiresAt) { + return { success: false, error: 'Device code expired. Please try again.' } + } + + try { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + device_code: deviceFlowState.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }) + console.log('Poll response status:', response.status) + + const data = (await response.json()) as { + access_token?: string + error?: string + error_description?: string + interval?: number + } + console.log('Poll response data:', data) + + if (data.error) { + if (data.error === 'authorization_pending') { + return { success: false, error: 'authorization_pending' } + } else if (data.error === 'slow_down') { + return { success: false, error: 'slow_down', nextInterval: data.interval || deviceFlowState.interval + 5 } + } else if (data.error === 'expired_token') { + return { success: false, error: 'Device code expired. Please try again.' } + } else if (data.error === 'access_denied') { + return { success: false, error: 'Authorization denied by user.' } + } else { + return { success: false, error: data.error_description || data.error } + } + } + + if (data.access_token) { + authState.accessToken = data.access_token + const userResponse = await fetch(`${GITHUB_API_BASE}/user`, { + headers: { + Authorization: `Bearer ${authState.accessToken}`, + }, + }) + + if (userResponse.ok) { + const userData = (await userResponse.json()) as { login: string } + authState.username = userData.login + } + + saveAuthState() + + deviceFlowState.deviceCode = null + deviceFlowState.userCode = null + deviceFlowState.verificationUri = null + deviceFlowState.expiresAt = null + + logger.info(`GitHub Device Flow completed. User: ${authState.username}`) + + return { success: true, username: authState.username || undefined } + } + + return { success: false, error: 'Unknown error during authorization' } + } catch (error) { + logger.error(`Failed to poll device flow authorization: ${error}`) + return { success: false, error: String(error) } + } +} + +function cancelDeviceFlow() { + if (deviceFlowState.pollingTimer) { + clearInterval(deviceFlowState.pollingTimer) + deviceFlowState.pollingTimer = null + } + deviceFlowState.deviceCode = null + deviceFlowState.userCode = null + deviceFlowState.verificationUri = null + deviceFlowState.expiresAt = null +} + +async function shareScript( + scriptPath: string[], + metadata: { name: string; author: string; description: string }, +): Promise<{ success: boolean; prUrl?: string; error?: string }> { + if (!authState.accessToken) { + return { success: false, error: 'Not authenticated with GitHub' } + } + + try { + const localPath = path.join(scriptsDir(), ...scriptPath) + if (!fs.existsSync(localPath)) { + return { success: false, error: 'Script file not found' } + } + + let content = fs.readFileSync(localPath, 'utf-8') + + const metaHeader = `/** + * @name ${metadata.name} + * @author ${metadata.author} + * @description ${metadata.description} + * @version 1.0.0 + */ + +` + + if (!content.startsWith('/**')) { + content = metaHeader + content + } + + const category = scriptPath.length > 1 ? scriptPath.slice(0, -1).join('/') : 'manualTrigger' + const fileName = `${metadata.author}-${scriptPath[scriptPath.length - 1]}` + const filePath = `${category}/${fileName}` + + const forkResponse = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/forks`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authState.accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + if (!forkResponse.ok && forkResponse.status !== 202) { + const forkError = await forkResponse.text() + logger.error(`Fork response: ${forkError}`) + } + + await new Promise(resolve => setTimeout(resolve, 2000)) + + const mainBranchResponse = await fetch( + `${GITHUB_API_BASE}/repos/${authState.username}/${GITHUB_REPO_NAME}/git/ref/heads/main`, + { + headers: { + Authorization: `Bearer ${authState.accessToken}`, + }, + }, + ) + + let baseSha: string + if (mainBranchResponse.ok) { + const mainBranchData = (await mainBranchResponse.json()) as { object: { sha: string } } + baseSha = mainBranchData.object.sha + } else { + const origBranchResponse = await fetch( + `${GITHUB_API_BASE}/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/git/ref/heads/main`, + { + headers: { + Authorization: `Bearer ${authState.accessToken}`, + }, + }, + ) + const origBranchData = (await origBranchResponse.json()) as { object: { sha: string } } + baseSha = origBranchData.object.sha + } + + const branchName = `add-script-${Date.now()}` + await fetch(`${GITHUB_API_BASE}/repos/${authState.username}/${GITHUB_REPO_NAME}/git/refs`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authState.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ref: `refs/heads/${branchName}`, + sha: baseSha, + }), + }) + + const fileResponse = await fetch( + `${GITHUB_API_BASE}/repos/${authState.username}/${GITHUB_REPO_NAME}/contents/${filePath}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${authState.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: `Add script: ${metadata.name}`, + content: Buffer.from(content).toString('base64'), + branch: branchName, + }), + }, + ) + + if (!fileResponse.ok) { + const errorData = await fileResponse.text() + return { success: false, error: `Failed to create file: ${errorData}` } + } + + const prResponse = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/pulls`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authState.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: `Add script: ${metadata.name}`, + body: `## Script Information\n\n- **Name**: ${metadata.name}\n- **Author**: ${metadata.author}\n- **Description**: ${metadata.description}\n\nSubmitted via PicList Script Marketplace`, + head: `${authState.username}:${branchName}`, + base: 'main', + }), + }) + + if (!prResponse.ok) { + const prError = await prResponse.text() + return { success: false, error: `Failed to create PR: ${prError}` } + } + + const prData = (await prResponse.json()) as { html_url: string } + return { success: true, prUrl: prData.html_url } + } catch (error) { + logger.error(`Failed to share script: ${error}`) + return { success: false, error: String(error) } + } +} + +loadAuthState() + +const scriptMarketplaceRouter = new RPCRouter() + +const scriptMarketplaceRoutes = [ + { + action: IRPCActionType.SCRIPT_MARKETPLACE_FETCH_LIST, + handler: async () => { + return await fetchScriptsList() + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_DOWNLOAD, + handler: async (_: IIPCEvent, args: [IScriptMeta]) => { + return await downloadScript(args[0]) + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_SHARE, + handler: async (_: IIPCEvent, args: [string[], { name: string; author: string; description: string }]) => { + return await shareScript(args[0], args[1]) + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_CHECK_GITHUB_AUTH, + handler: async () => { + await loadAuthState() + return { + isAuthenticated: !!authState.accessToken, + username: authState.username, + } + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_LOGIN, + handler: async () => { + return await initiateGitHubDeviceFlow() + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_POLL, + handler: async () => { + return await pollDeviceFlowAuthorization() + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_CANCEL, + handler: async () => { + cancelDeviceFlow() + return true + }, + type: IRPCType.INVOKE, + }, + { + action: IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_LOGOUT, + handler: async () => { + clearAuthState() + cancelDeviceFlow() + return true + }, + type: IRPCType.INVOKE, + }, +] + +scriptMarketplaceRouter.addBatch(scriptMarketplaceRoutes) + +export { scriptMarketplaceRouter } diff --git a/src/main/utils/enum.ts b/src/main/utils/enum.ts index 4a6875ea..771bcd26 100644 --- a/src/main/utils/enum.ts +++ b/src/main/utils/enum.ts @@ -140,6 +140,16 @@ export const IRPCActionType = { DELETE_SCRIPTS_FILE: 'DELETE_SCRIPTS_FILE', RUN_SCRIPT_FILE: 'RUN_SCRIPT_FILE', + // script marketplace rpc + SCRIPT_MARKETPLACE_FETCH_LIST: 'SCRIPT_MARKETPLACE_FETCH_LIST', + SCRIPT_MARKETPLACE_DOWNLOAD: 'SCRIPT_MARKETPLACE_DOWNLOAD', + SCRIPT_MARKETPLACE_SHARE: 'SCRIPT_MARKETPLACE_SHARE', + SCRIPT_MARKETPLACE_CHECK_GITHUB_AUTH: 'SCRIPT_MARKETPLACE_CHECK_GITHUB_AUTH', + SCRIPT_MARKETPLACE_GITHUB_LOGIN: 'SCRIPT_MARKETPLACE_GITHUB_LOGIN', + SCRIPT_MARKETPLACE_GITHUB_POLL: 'SCRIPT_MARKETPLACE_GITHUB_POLL', + SCRIPT_MARKETPLACE_GITHUB_CANCEL: 'SCRIPT_MARKETPLACE_GITHUB_CANCEL', + SCRIPT_MARKETPLACE_GITHUB_LOGOUT: 'SCRIPT_MARKETPLACE_GITHUB_LOGOUT', + // shortkey setting rpc SHORTKEY_UPDATE: 'SHORTKEY_UPDATE', SHORTKEY_BIND_OR_UNBIND: 'SHORTKEY_BIND_OR_UNBIND', diff --git a/src/renderer/components/Editor.vue b/src/renderer/components/Editor.vue index 9787a97d..34aed895 100644 --- a/src/renderer/components/Editor.vue +++ b/src/renderer/components/Editor.vue @@ -16,6 +16,7 @@ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' const props = defineProps({ modelValue: { type: String, default: '' }, language: { type: String, default: 'javascript' }, + readOnly: { type: Boolean, default: false }, }) const emit = defineEmits(['update:modelValue']) @@ -35,6 +36,7 @@ onMounted(() => { search({ top: true }), keymap.of([...searchKeymap]), EditorView.lineWrapping, + EditorView.editable.of(!props.readOnly), EditorView.updateListener.of(update => { if (update.docChanged) { emit('update:modelValue', update.state.doc.toString()) diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index dc710cd9..eade1d79 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -736,6 +736,55 @@ "emptyScriptList": "Script list is empty", "enabled": "Enabled", "enableScript": "Enable Script", + "marketplace": { + "allCategories": "All Categories", + "author": "Author", + "browseMarketplace": "Browse Marketplace", + "category": "Category", + "codeCopied": "Code copied to clipboard", + "copyCode": "Copy code", + "description": "Browse and download community scripts", + "deviceCodeExpired": "Device code expired. Please try again.", + "deviceFlowInstructions": "A browser window has opened. Enter the code below on GitHub to authorize PicList.", + "download": "Download", + "downloaded": "Downloaded", + "downloadFailed": "Failed to download script", + "downloading": "Downloading...", + "downloadSuccess": "Script downloaded successfully", + "filterByCategory": "Filter by category", + "loadFailed": "Failed to load marketplace scripts", + "loadingScripts": "Loading scripts...", + "loggedInAs": "Logged in as {username}", + "loginFailed": "GitHub login failed", + "loginRequired": "Please login with GitHub to share scripts", + "loginSuccess": "GitHub login successful", + "loginWithGitHub": "Login with GitHub", + "logout": "Logout", + "logoutSuccess": "Logged out successfully", + "metadataRequired": "Please fill in all metadata fields", + "noScriptsFound": "No scripts found in marketplace", + "openMarketplaceRepo": "Open Marketplace Repository", + "prCreated": "Pull request created successfully", + "retry": "Retry", + "scriptAuthor": "Author Name", + "scriptContent": "Script Content", + "scriptDescription": "Description", + "scriptMetadata": "Script Metadata", + "scriptName": "Script Name", + "scriptVersion": "脚本版本", + "searchPlaceholder": "Search scripts...", + "share": "Share Script", + "shareFailed": "Failed to share script", + "shareScript": "Share to Marketplace", + "shareSuccess": "Script shared successfully! PR created.", + "sharing": "Sharing...", + "showScriptCode": "Show Code", + "title": "Script Marketplace", + "version": "Version", + "viewPR": "View Pull Request", + "waitingForAuth": "Waiting for authorization...", + "yourCode": "Your verification code:" + }, "newScript": "New Script", "NoScripts": "No Scripts", "noScriptsFound": "No Scripts Found", diff --git a/src/renderer/i18n/locales/zh-CN.json b/src/renderer/i18n/locales/zh-CN.json index fbe61c20..a72ce54b 100644 --- a/src/renderer/i18n/locales/zh-CN.json +++ b/src/renderer/i18n/locales/zh-CN.json @@ -736,6 +736,55 @@ "emptyScriptList": "脚本列表为空", "enabled": "已启用", "enableScript": "启用脚本", + "marketplace": { + "allCategories": "全部分类", + "author": "作者", + "browseMarketplace": "浏览市场", + "category": "分类", + "codeCopied": "代码已复制到剪贴板", + "copyCode": "复制代码", + "description": "浏览和下载社区脚本", + "deviceCodeExpired": "设备代码已过期,请重试。", + "deviceFlowInstructions": "浏览器窗口已打开。请在 GitHub 上输入以下代码以授权 PicList。", + "download": "下载", + "downloaded": "已下载", + "downloadFailed": "脚本下载失败", + "downloading": "下载中...", + "downloadSuccess": "脚本下载成功", + "filterByCategory": "按分类筛选", + "loadFailed": "加载市场脚本失败", + "loadingScripts": "正在加载脚本...", + "loggedInAs": "已登录为 {username}", + "loginFailed": "GitHub 登录失败", + "loginRequired": "请使用 GitHub 登录以分享脚本", + "loginSuccess": "GitHub 登录成功", + "loginWithGitHub": "使用 GitHub 登录", + "logout": "退出登录", + "logoutSuccess": "已退出登录", + "metadataRequired": "请填写所有元数据字段", + "noScriptsFound": "市场中暂无脚本", + "openMarketplaceRepo": "打开市场仓库", + "prCreated": "Pull Request 创建成功", + "retry": "重试", + "scriptAuthor": "作者名称", + "scriptContent": "脚本内容", + "scriptDescription": "描述", + "scriptMetadata": "脚本元数据", + "scriptName": "脚本名称", + "scriptVersion": "脚本版本", + "searchPlaceholder": "搜索脚本...", + "share": "分享脚本", + "shareFailed": "脚本分享失败", + "shareScript": "分享到市场", + "shareSuccess": "脚本分享成功!已创建 PR。", + "sharing": "分享中...", + "showScriptCode": "显示代码", + "title": "脚本市场", + "version": "版本", + "viewPR": "查看 Pull Request", + "waitingForAuth": "等待授权中...", + "yourCode": "您的验证码:" + }, "newScript": "新脚本", "NoScripts": "暂无脚本", "noScriptsFound": "未找到脚本", diff --git a/src/renderer/i18n/locales/zh-TW.json b/src/renderer/i18n/locales/zh-TW.json index b6f4ee70..18d1c75e 100644 --- a/src/renderer/i18n/locales/zh-TW.json +++ b/src/renderer/i18n/locales/zh-TW.json @@ -736,6 +736,55 @@ "emptyScriptList": "腳本列表為空", "enabled": "已啟用", "enableScript": "啟用腳本", + "marketplace": { + "allCategories": "全部分類", + "author": "作者", + "browseMarketplace": "瀏覽市場", + "category": "分類", + "codeCopied": "代碼已複製到剪貼板", + "copyCode": "複製代碼", + "description": "瀏覽和下載社區腳本", + "deviceCodeExpired": "設備代碼已過期,請重試。", + "deviceFlowInstructions": "瀏覽器視窗已開啟。請在 GitHub 上輸入以下代碼以授權 PicList。", + "download": "下載", + "downloaded": "已下載", + "downloadFailed": "腳本下載失敗", + "downloading": "下載中...", + "downloadSuccess": "腳本下載成功", + "filterByCategory": "按分類篩選", + "loadFailed": "加載市場腳本失敗", + "loadingScripts": "正在加載腳本...", + "loggedInAs": "已登錄為 {username}", + "loginFailed": "GitHub 登錄失敗", + "loginRequired": "請使用 GitHub 登錄以分享腳本", + "loginSuccess": "GitHub 登錄成功", + "loginWithGitHub": "使用 GitHub 登錄", + "logout": "退出登錄", + "logoutSuccess": "已退出登錄", + "metadataRequired": "請填寫所有元數據字段", + "noScriptsFound": "市場中暫無腳本", + "openMarketplaceRepo": "打開市場倉庫", + "prCreated": "Pull Request 創建成功", + "retry": "重試", + "scriptAuthor": "作者名稱", + "scriptContent": "腳本內容", + "scriptDescription": "描述", + "scriptMetadata": "腳本元數據", + "scriptName": "腳本名稱", + "scriptVersion": "脚本版本", + "searchPlaceholder": "搜索腳本...", + "share": "分享腳本", + "shareFailed": "腳本分享失敗", + "shareScript": "分享到市場", + "shareSuccess": "腳本分享成功!已創建 PR。", + "sharing": "分享中...", + "showScriptCode": "顯示代碼", + "title": "腳本市場", + "version": "版本", + "viewPR": "查看 Pull Request", + "waitingForAuth": "等待授權中...", + "yourCode": "您的驗證碼:" + }, "newScript": "新腳本", "NoScripts": "暫無腳本", "noScriptsFound": "未找到腳本", diff --git a/src/renderer/pages/ScriptPage.vue b/src/renderer/pages/ScriptPage.vue index 41c092ca..2e8edf07 100644 --- a/src/renderer/pages/ScriptPage.vue +++ b/src/renderer/pages/ScriptPage.vue @@ -12,6 +12,12 @@
+ + + + +
+ + +
+ + +
+ + + + + + +
+
+ + GitHub + + +

{{ t('pages.scripts.marketplace.loginRequired') }}

+ + + +
+ +
+ +
+ + +
+ + GitHub + + +

+ {{ t('pages.scripts.marketplace.deviceFlowInstructions') }} +

+
+ {{ t('pages.scripts.marketplace.yourCode') }} +
+ + {{ deviceFlowState.userCode }} + + +
+
+
+
+ {{ t('pages.scripts.marketplace.waitingForAuth') }} +
+
+ +
@@ -214,14 +525,25 @@ import dayjs from 'dayjs' import { CheckCircle2, + CheckIcon, Clock, + DownloadIcon, Edit2Icon, + ExternalLinkIcon, FileCode, FolderOpen, + LogOutIcon, + PackageIcon, Pencil, Play, Plus, + RefreshCwIcon, + SearchIcon, + Share2Icon, + StoreIcon, Trash2, + UserIcon, + XCircleIcon, XIcon, } from 'lucide-vue-next' import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue' @@ -255,6 +577,81 @@ const newScriptNameVisible = ref(false) const newScriptName = ref('') const newScriptCategory = ref('manualTrigger') +// Marketplace related state +interface IScriptMeta { + name: string + author: string + description: string + version: string + fileName: string + category: string + content: string | null + downloadUrl: string +} + +interface IGitHubAuth { + isAuthenticated: boolean + username: string | null +} + +interface IDeviceFlowState { + isActive: boolean + userCode: string | null + verificationUri: string | null + expiresAt: number | null + pollingInterval: NodeJS.Timeout | null +} + +const marketplaceVisible = ref(false) +const marketplaceLoading = ref(false) +const marketplaceError = ref(false) +const marketplaceScripts = ref([]) +const marketplaceSearch = ref('') +const marketplaceScriptContent = ref('') +const marketplaceCategoryFilter = ref([]) +const downloadingScripts = ref>(new Set()) +const githubAuth = ref({ isAuthenticated: false, username: null }) + +// Device Flow state +const deviceFlowState = ref({ + isActive: false, + userCode: null, + verificationUri: null, + expiresAt: null, + pollingInterval: null, +}) +const deviceFlowDialogVisible = ref(false) +const scriptContentOfMarketplaceVisible = ref(false) +const shareDialogVisible = ref(false) +const sharingScript = ref(false) +const scriptToShare = ref(null) +const shareMetadata = ref({ + name: '', + author: '', + description: '', + version: '1.0.0', +}) + +const filteredMarketplaceScripts = computed(() => { + let scripts = marketplaceScripts.value + + if (marketplaceSearch.value) { + const search = marketplaceSearch.value.toLowerCase() + scripts = scripts.filter( + s => + s.name.toLowerCase().includes(search) || + s.description.toLowerCase().includes(search) || + s.author.toLowerCase().includes(search), + ) + } + + if (marketplaceCategoryFilter.value.length > 0) { + scripts = scripts.filter(s => marketplaceCategoryFilter.value.includes(s.category)) + } + + return scripts +}) + const supportedScriptCategories = [ { type: 'onSoftwareOpen', name: t('pages.scripts.scriptsTypes.onSoftwareOpen') }, { type: 'onSoftwareClose', name: t('pages.scripts.scriptsTypes.onSoftwareClose') }, @@ -437,11 +834,240 @@ async function toggleScript(scriptPath: string[]) { await getScriptsMap() } +function openScriptDetails(content: string) { + marketplaceScriptContent.value = content + scriptContentOfMarketplaceVisible.value = true +} + +async function openMarketplace() { + marketplaceVisible.value = true + await checkGitHubAuth() + await fetchMarketplaceScripts() +} + +async function fetchMarketplaceScripts() { + marketplaceLoading.value = true + marketplaceError.value = false + + try { + const scripts = await window.electron.triggerRPC(IRPCActionType.SCRIPT_MARKETPLACE_FETCH_LIST) + marketplaceScripts.value = scripts || [] + console.log('Fetched marketplace scripts:', marketplaceScripts.value) + } catch (error) { + console.error('Failed to fetch marketplace scripts:', error) + marketplaceError.value = true + } finally { + marketplaceLoading.value = false + } +} + +function isScriptDownloaded(script: IScriptMeta): boolean { + const categoryPath = script.category.replace(/\./g, '/') + const scriptPath = `${categoryPath}/${script.fileName}` + const isDownloaded = existingPathsSet.value.has(scriptPath) + return isDownloaded +} + +async function downloadMarketplaceScript(script: IScriptMeta) { + downloadingScripts.value.add(script.downloadUrl) + + try { + const result = await window.electron.triggerRPC( + IRPCActionType.SCRIPT_MARKETPLACE_DOWNLOAD, + getRawData(script), + ) + if (result) { + message.success(t('pages.scripts.marketplace.downloadSuccess')) + await getScriptsMap() + } else { + message.error(t('pages.scripts.marketplace.downloadFailed')) + } + } catch (error) { + console.error('Failed to download script:', error) + message.error(t('pages.scripts.marketplace.downloadFailed')) + } finally { + downloadingScripts.value.delete(script.downloadUrl) + } +} + +async function checkGitHubAuth() { + try { + const auth = await window.electron.triggerRPC(IRPCActionType.SCRIPT_MARKETPLACE_CHECK_GITHUB_AUTH) + if (auth) { + githubAuth.value = auth + } + } catch (error) { + console.error('Failed to check GitHub auth:', error) + } +} + +async function handleGitHubLogin() { + try { + const result = await window.electron.triggerRPC<{ + userCode: string + verificationUri: string + expiresIn: number + } | null>(IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_LOGIN) + + if (result) { + deviceFlowState.value = { + isActive: true, + userCode: result.userCode, + verificationUri: result.verificationUri, + expiresAt: Date.now() + result.expiresIn * 1000, + pollingInterval: null, + } + deviceFlowDialogVisible.value = true + + startDeviceFlowPolling() + } else { + message.error(t('pages.scripts.marketplace.loginFailed')) + } + } catch (error) { + console.error('Failed to initiate GitHub login:', error) + message.error(t('pages.scripts.marketplace.loginFailed')) + } +} + +function startDeviceFlowPolling() { + if (deviceFlowState.value.pollingInterval) { + clearInterval(deviceFlowState.value.pollingInterval) + } + + const poll = async () => { + if (deviceFlowState.value.expiresAt && Date.now() > deviceFlowState.value.expiresAt) { + stopDeviceFlowPolling() + message.error(t('pages.scripts.marketplace.deviceCodeExpired')) + deviceFlowDialogVisible.value = false + return + } + + try { + const result = await window.electron.triggerRPC<{ + success: boolean + username?: string + error?: string + nextInterval?: number + }>(IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_POLL) + + if (result?.success) { + stopDeviceFlowPolling() + githubAuth.value = { isAuthenticated: true, username: result.username || null } + deviceFlowDialogVisible.value = false + message.success(t('pages.scripts.marketplace.loginSuccess')) + return + } + + if (result?.error === 'authorization_pending' || result?.error === 'slow_down') { + const delay = (result.nextInterval || 5) * 1000 + deviceFlowState.value.pollingInterval = setTimeout(poll, delay) + } else { + stopDeviceFlowPolling() + deviceFlowDialogVisible.value = false + message.error(`${t('pages.scripts.marketplace.loginFailed')}: ${result?.error}`) + } + } catch (error) { + console.error('Failed to poll device flow:', error) + deviceFlowState.value.pollingInterval = setTimeout(poll, 5000) + } + } + + poll() +} + +function stopDeviceFlowPolling() { + if (deviceFlowState.value.pollingInterval) { + clearInterval(deviceFlowState.value.pollingInterval) + deviceFlowState.value.pollingInterval = null + } + deviceFlowState.value.isActive = false +} + +async function cancelDeviceFlow() { + stopDeviceFlowPolling() + await window.electron.triggerRPC(IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_CANCEL) + deviceFlowDialogVisible.value = false +} + +function copyUserCode() { + if (deviceFlowState.value.userCode) { + window.electron.clipboard.writeText(deviceFlowState.value.userCode) + message.success(t('pages.scripts.marketplace.codeCopied')) + } +} + +async function handleGitHubLogout() { + try { + await window.electron.triggerRPC(IRPCActionType.SCRIPT_MARKETPLACE_GITHUB_LOGOUT) + githubAuth.value = { isAuthenticated: false, username: null } + message.success(t('pages.scripts.marketplace.logoutSuccess')) + } catch (error) { + console.error('Failed to logout:', error) + } +} + +function openMarketplaceRepo() { + window.electron.sendRPC(IRPCActionType.OPEN_URL, 'https://github.com/Kuingsmile/piclist-ScriptsHub') +} + +function openShareDialog(script: IStringKeyMap) { + scriptToShare.value = script + shareMetadata.value = { + name: script.fileName.replace('.js', ''), + author: githubAuth.value.username || '', + description: '', + version: '1.0.0', + } + shareDialogVisible.value = true +} + +async function handleShareScript() { + if (!scriptToShare.value) return + + if ( + !shareMetadata.value.name || + !shareMetadata.value.author || + !shareMetadata.value.description || + !shareMetadata.value.version + ) { + message.error(t('pages.scripts.marketplace.metadataRequired')) + return + } + + sharingScript.value = true + + try { + const result = await window.electron.triggerRPC<{ success: boolean; prUrl?: string; error?: string }>( + IRPCActionType.SCRIPT_MARKETPLACE_SHARE, + getRawData(scriptToShare.value.filePath), + getRawData(shareMetadata.value), + ) + + if (result?.success) { + message.success(t('pages.scripts.marketplace.shareSuccess')) + shareDialogVisible.value = false + if (result.prUrl) { + window.electron.sendRPC(IRPCActionType.OPEN_URL, result.prUrl) + } + } else { + message.error(`${t('pages.scripts.marketplace.shareFailed')}: ${result?.error || 'Unknown error'}`) + } + } catch (error) { + console.error('Failed to share script:', error) + message.error(t('pages.scripts.marketplace.shareFailed')) + } finally { + sharingScript.value = false + } +} + onBeforeMount(async () => { getScriptsMap() + await checkGitHubAuth() }) -onBeforeUnmount(() => {}) +onBeforeUnmount(() => { + stopDeviceFlowPolling() +})