mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-09-20 11:17:32 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9acceea42b | ||
|
|
dcc64b61d7 | ||
|
|
bad47fb145 | ||
|
|
e9d9ae66e9 | ||
|
|
9506aadef5 | ||
|
|
3980ef89fa | ||
|
|
45fd078e4e | ||
|
|
4676326eb8 | ||
|
|
33bbc9a512 | ||
|
|
77f1ed16b7 |
@@ -333,10 +333,10 @@ jobs:
|
||||
- name: List dist
|
||||
run: ls -la dist/
|
||||
|
||||
- name: Upload to release.picgo.app
|
||||
- name: Upload update metadata to release.picgo.app
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.test_upload_dist || github.event.inputs.test_upload_dist_to_dev
|
||||
run: |
|
||||
ARGS="--all"
|
||||
ARGS="--all --version-files-only"
|
||||
|
||||
if [[ "${{ github.event.inputs.test_upload_dist_to_dev }}" == "true" ]]; then
|
||||
ARGS="$ARGS --dev"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Mirror Release to AtomGit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- atomgit-release-ci-test
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Published GitHub release tag to mirror
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mirror-atomgit-${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror-release:
|
||||
name: Mirror GitHub Release to AtomGit
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag || 'v3.0.2' }}
|
||||
ATOM_OWNER: ${{ vars.ATOM_OWNER || 'Molunerfinn' }}
|
||||
ATOM_REPO: ${{ vars.ATOM_REPO || 'PicGo' }}
|
||||
ATOM_TARGET_COMMITISH: ${{ vars.ATOM_TARGET_COMMITISH }}
|
||||
ATOM_UPLOAD_TIMEOUT_MINUTES: ${{ vars.ATOM_UPLOAD_TIMEOUT_MINUTES || '70' }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Read GitHub release metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release view "$RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,name,body,isDraft,isPrerelease,targetCommitish \
|
||||
> github-release.json
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
gh release download "$RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist
|
||||
|
||||
- name: Publish AtomGit release
|
||||
env:
|
||||
ATOM_PAT: ${{ secrets.ATOM_PAT }}
|
||||
run: node scripts/publish-atomgit-release.js
|
||||
@@ -1,3 +1,17 @@
|
||||
## :tada: 3.0.2 (2026-08-14)
|
||||
|
||||
|
||||
### :bug: Bug Fixes
|
||||
|
||||
* **gui:** protect persisted provider credentials ([77f1ed1](https://github.com/Molunerfinn/PicGo/commit/77f1ed1))
|
||||
|
||||
|
||||
### :zap: Performance Improvements
|
||||
|
||||
* **gui:** stop idle GPU churn from uploader status dot ([#1436](https://github.com/Molunerfinn/PicGo/issues/1436)) ([33bbc9a](https://github.com/Molunerfinn/PicGo/commit/33bbc9a))
|
||||
|
||||
|
||||
|
||||
## :tada: 3.0.1 (2026-07-12)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picgo",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2",
|
||||
"private": true,
|
||||
"main": "dist_electron/main/index.js",
|
||||
"description": "A powerful & simple image uploader for creators.",
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const path = require('path')
|
||||
|
||||
const API_URL = process.env.ATOM_API_URL || 'https://api.atomgit.com/api/v5'
|
||||
const ATOM_PAT = process.env.ATOM_PAT
|
||||
const ATOM_OWNER = process.env.ATOM_OWNER
|
||||
const ATOM_REPO = process.env.ATOM_REPO
|
||||
const RELEASE_TAG = process.env.RELEASE_TAG
|
||||
const TARGET_COMMITISH = process.env.ATOM_TARGET_COMMITISH
|
||||
const DIST_DIR = path.resolve(process.env.DIST_DIR || 'dist')
|
||||
const RELEASE_METADATA_PATH = path.resolve(process.env.GITHUB_RELEASE_JSON || 'github-release.json')
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504])
|
||||
const UPLOAD_TIMEOUT_MINUTES = Number.parseInt(process.env.ATOM_UPLOAD_TIMEOUT_MINUTES || '70', 10)
|
||||
|
||||
function requireEnvironmentVariable(name, value) {
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
function encodePathSegment(value) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function wait(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
return `${Math.round(milliseconds / 1000)}s`
|
||||
}
|
||||
|
||||
async function requestWithRetry(url, createOptions, label) {
|
||||
let lastError
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, createOptions())
|
||||
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
|
||||
return response
|
||||
}
|
||||
|
||||
await response.text()
|
||||
console.warn(`[AtomGit] ${label} returned ${response.status}; retrying (${attempt}/${MAX_ATTEMPTS})`)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt === MAX_ATTEMPTS) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.warn(`[AtomGit] ${label} failed; retrying (${attempt}/${MAX_ATTEMPTS})`)
|
||||
}
|
||||
|
||||
await wait(1000 * attempt)
|
||||
}
|
||||
|
||||
throw lastError || new Error(`${label} failed after ${MAX_ATTEMPTS} attempts`)
|
||||
}
|
||||
|
||||
function atomGitApiUrl(relativePath) {
|
||||
return `${API_URL}/repos/${encodePathSegment(ATOM_OWNER)}/${encodePathSegment(ATOM_REPO)}${relativePath}`
|
||||
}
|
||||
|
||||
function atomGitHeaders(additionalHeaders = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${ATOM_PAT}`,
|
||||
Accept: 'application/json',
|
||||
...additionalHeaders
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponseText(text) {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseBody(response) {
|
||||
return parseResponseText(await response.text())
|
||||
}
|
||||
|
||||
function formatResponseBody(body) {
|
||||
return typeof body === 'string' ? body : JSON.stringify(body)
|
||||
}
|
||||
|
||||
async function assertSuccessfulResponse(response, label) {
|
||||
const body = await readResponseBody(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(`${label} failed with HTTP ${response.status}: ${formatResponseBody(body)}`)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
async function verifyCredentials() {
|
||||
const response = await requestWithRetry(
|
||||
`${API_URL}/user`,
|
||||
() => ({ headers: atomGitHeaders() }),
|
||||
'credential verification'
|
||||
)
|
||||
await assertSuccessfulResponse(response, 'AtomGit credential verification')
|
||||
}
|
||||
|
||||
async function getRelease() {
|
||||
const response = await requestWithRetry(
|
||||
atomGitApiUrl(`/releases/tags/${encodePathSegment(RELEASE_TAG)}`),
|
||||
() => ({ headers: atomGitHeaders() }),
|
||||
`get release ${RELEASE_TAG}`
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
await response.text()
|
||||
return null
|
||||
}
|
||||
|
||||
return assertSuccessfulResponse(response, `Get AtomGit release ${RELEASE_TAG}`)
|
||||
}
|
||||
|
||||
async function createRelease(metadata) {
|
||||
const name = metadata.name || RELEASE_TAG
|
||||
const payload = {
|
||||
tag_name: RELEASE_TAG,
|
||||
name,
|
||||
body: metadata.body || `Release ${name}`,
|
||||
release_status: metadata.isPrerelease ? 'pre' : 'latest'
|
||||
}
|
||||
const targetCommitish = TARGET_COMMITISH || metadata.targetCommitish
|
||||
|
||||
if (targetCommitish) {
|
||||
payload.target_commitish = targetCommitish
|
||||
}
|
||||
|
||||
const response = await requestWithRetry(
|
||||
atomGitApiUrl('/releases'),
|
||||
() => ({
|
||||
method: 'POST',
|
||||
headers: atomGitHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
`create release ${RELEASE_TAG}`
|
||||
)
|
||||
|
||||
if (response.status === 409) {
|
||||
await response.text()
|
||||
return getRelease()
|
||||
}
|
||||
|
||||
return assertSuccessfulResponse(response, `Create AtomGit release ${RELEASE_TAG}`)
|
||||
}
|
||||
|
||||
async function getOrCreateRelease(metadata) {
|
||||
const existingRelease = await getRelease()
|
||||
if (existingRelease) {
|
||||
console.log(`[AtomGit] Release ${RELEASE_TAG} already exists`)
|
||||
return existingRelease
|
||||
}
|
||||
|
||||
console.log(`[AtomGit] Creating release ${RELEASE_TAG}`)
|
||||
const createdRelease = await createRelease(metadata)
|
||||
if (!createdRelease) {
|
||||
throw new Error(`AtomGit release ${RELEASE_TAG} was not available after creation`)
|
||||
}
|
||||
|
||||
return createdRelease
|
||||
}
|
||||
|
||||
async function getUploadRequest(fileName) {
|
||||
const url = new URL(atomGitApiUrl(`/releases/${encodePathSegment(RELEASE_TAG)}/upload_url`))
|
||||
url.searchParams.set('file_name', fileName)
|
||||
|
||||
const response = await requestWithRetry(
|
||||
url,
|
||||
() => ({ headers: atomGitHeaders() }),
|
||||
`request upload URL for ${fileName}`
|
||||
)
|
||||
const body = await assertSuccessfulResponse(response, `Get upload URL for ${fileName}`)
|
||||
|
||||
if (!body || typeof body !== 'object' || typeof body.url !== 'string' || !body.headers || typeof body.headers !== 'object') {
|
||||
throw new Error(`AtomGit returned an invalid upload request for ${fileName}`)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function putAssetBuffer(url, headers, fileBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targetUrl = new URL(url)
|
||||
const requestClient = targetUrl.protocol === 'http:' ? http : https
|
||||
const request = requestClient.request(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: Object.fromEntries(headers.entries())
|
||||
}, response => {
|
||||
const chunks = []
|
||||
|
||||
response.on('data', chunk => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
})
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
status: response.statusCode || 0,
|
||||
body: Buffer.concat(chunks).toString('utf8')
|
||||
})
|
||||
})
|
||||
response.on('error', reject)
|
||||
})
|
||||
|
||||
request.setTimeout(UPLOAD_TIMEOUT_MINUTES * 60 * 1000, () => {
|
||||
request.destroy(new Error(`Upload request timed out after ${UPLOAD_TIMEOUT_MINUTES} minutes`))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.end(fileBuffer)
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadAsset(filePath) {
|
||||
const fileName = path.basename(filePath)
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
const fileSize = fileBuffer.byteLength
|
||||
const startedAt = Date.now()
|
||||
|
||||
console.log(`[AtomGit] Uploading ${fileName} (${formatFileSize(fileSize)})`)
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
const uploadRequest = await getUploadRequest(fileName)
|
||||
const uploadHeaders = new Headers(uploadRequest.headers)
|
||||
uploadHeaders.set('Content-Length', String(fileSize))
|
||||
let response
|
||||
|
||||
try {
|
||||
response = await putAssetBuffer(uploadRequest.url, uploadHeaders, fileBuffer)
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.warn(`[AtomGit] Upload ${fileName} failed; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
|
||||
await wait(1000 * attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
console.log(`[AtomGit] Uploaded ${fileName} in ${formatDuration(Date.now() - startedAt)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const responseBody = parseResponseText(response.body)
|
||||
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
|
||||
throw new Error(`Upload ${fileName} failed with HTTP ${response.status}: ${formatResponseBody(responseBody)}`)
|
||||
}
|
||||
|
||||
console.warn(`[AtomGit] Upload ${fileName} returned ${response.status}; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
|
||||
await wait(1000 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
function readReleaseMetadata() {
|
||||
const metadata = JSON.parse(fs.readFileSync(RELEASE_METADATA_PATH, 'utf8'))
|
||||
if (metadata.isDraft) {
|
||||
throw new Error(`GitHub release ${RELEASE_TAG} is still a draft and will not be mirrored`)
|
||||
}
|
||||
|
||||
if (metadata.tagName !== RELEASE_TAG) {
|
||||
throw new Error(`GitHub release tag ${metadata.tagName} does not match requested tag ${RELEASE_TAG}`)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
function findAssets() {
|
||||
return fs.readdirSync(DIST_DIR, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => path.join(DIST_DIR, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function getExistingAssetNames(release) {
|
||||
if (!Array.isArray(release.assets)) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(release.assets
|
||||
.map(asset => asset && asset.name)
|
||||
.filter(name => typeof name === 'string'))
|
||||
}
|
||||
|
||||
async function publishRelease() {
|
||||
requireEnvironmentVariable('ATOM_PAT', ATOM_PAT)
|
||||
requireEnvironmentVariable('ATOM_OWNER', ATOM_OWNER)
|
||||
requireEnvironmentVariable('ATOM_REPO', ATOM_REPO)
|
||||
requireEnvironmentVariable('RELEASE_TAG', RELEASE_TAG)
|
||||
if (!Number.isInteger(UPLOAD_TIMEOUT_MINUTES) || UPLOAD_TIMEOUT_MINUTES < 1 || UPLOAD_TIMEOUT_MINUTES > 180) {
|
||||
throw new Error('ATOM_UPLOAD_TIMEOUT_MINUTES must be an integer between 1 and 180')
|
||||
}
|
||||
|
||||
const metadata = readReleaseMetadata()
|
||||
const assetPaths = findAssets()
|
||||
if (assetPaths.length === 0) {
|
||||
throw new Error(`No GitHub release assets found in ${DIST_DIR}`)
|
||||
}
|
||||
|
||||
console.log(`[AtomGit] Mirroring ${assetPaths.length} assets to ${ATOM_OWNER}/${ATOM_REPO}@${RELEASE_TAG}`)
|
||||
await verifyCredentials()
|
||||
const release = await getOrCreateRelease(metadata)
|
||||
const existingAssetNames = getExistingAssetNames(release)
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
const fileName = path.basename(assetPath)
|
||||
if (existingAssetNames.has(fileName)) {
|
||||
console.log(`[AtomGit] Skipping existing asset ${fileName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await uploadAsset(assetPath)
|
||||
}
|
||||
|
||||
console.log(`[AtomGit] Release ${RELEASE_TAG} mirror completed`)
|
||||
}
|
||||
|
||||
publishRelease().catch(error => {
|
||||
console.error('[AtomGit] Publish failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -11,6 +11,7 @@ const distPath = path.join(__dirname, '../dist')
|
||||
const S3Client = require('@aws-sdk/client-s3').S3Client
|
||||
const Upload = require('@aws-sdk/lib-storage').Upload
|
||||
const uploadToDev = process.argv.includes('--dev')
|
||||
const uploadVersionFilesOnly = process.argv.includes('--version-files-only')
|
||||
|
||||
const S3_BUCKET = 'release'
|
||||
const S3_LEGACY_BUCKET = 'picgo'
|
||||
@@ -103,6 +104,7 @@ const uploadDist = async () => {
|
||||
|
||||
console.log(`[PicGo] Upload mode: ${shouldUploadAll() ? 'ALL PLATFORMS' : process.platform}`)
|
||||
console.log(`[PicGo] Version: ${VERSION}`)
|
||||
console.log(`[PicGo] Install package upload: ${uploadVersionFilesOnly ? 'SKIPPED' : 'ENABLED'}`)
|
||||
console.log(`[PicGo] Total files to upload: ${configs.length}\n`)
|
||||
|
||||
const uploadedVersionFiles = new Set()
|
||||
@@ -117,11 +119,11 @@ const uploadDist = async () => {
|
||||
console.log(`[${index + 1}/${configs.length}] Processing ${fileName}`)
|
||||
|
||||
// 上传构建产物
|
||||
if (fs.existsSync(filePath)) {
|
||||
if (!uploadVersionFilesOnly && fs.existsSync(filePath)) {
|
||||
console.log(` Uploading to S3: ${FILE_PATH}${fileName}`)
|
||||
await uploadFileToS3(client, S3_BUCKET, `${FILE_PATH}${fileName}`, filePath)
|
||||
console.log(` ✅ Uploaded: ${fileName}`)
|
||||
} else {
|
||||
} else if (!uploadVersionFilesOnly) {
|
||||
console.warn(` ⚠️ File not found: ${fileName}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||
|
||||
type ConfigRecord = Record<string, unknown>
|
||||
|
||||
function isConfigRecord(value: unknown): value is ConfigRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (!isConfigRecord(current)) {
|
||||
return undefined
|
||||
}
|
||||
return current[key]
|
||||
}, value)
|
||||
}
|
||||
|
||||
describe('createSchemaOnlyUploaderContext', () => {
|
||||
it('hides only the target uploader configuration without mutating the source', () => {
|
||||
const config = {
|
||||
picBed: {
|
||||
current: 'tcyun',
|
||||
tcyun: {
|
||||
secretId: 'existing-secret-id',
|
||||
secretKey: 'existing-secret-key'
|
||||
},
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
},
|
||||
uploader: {
|
||||
tcyun: {
|
||||
defaultId: 'config-1'
|
||||
},
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
proxy: 'http://localhost:7890'
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
marker: 'original-context',
|
||||
getConfig<T>(name?: string): T {
|
||||
return (name ? getByPath(config, name) : config) as T
|
||||
}
|
||||
}
|
||||
|
||||
const schemaContext = createSchemaOnlyUploaderContext(context, 'tcyun')
|
||||
|
||||
expect(schemaContext.getConfig('picBed.tcyun')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('picBed.tcyun.secretKey')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('uploader.tcyun')).toBeUndefined()
|
||||
expect(schemaContext.getConfig('settings.proxy')).toBe('http://localhost:7890')
|
||||
expect(schemaContext.marker).toBe('original-context')
|
||||
|
||||
expect(schemaContext.getConfig<ConfigRecord>('picBed')).toEqual({
|
||||
current: 'tcyun',
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
})
|
||||
expect(schemaContext.getConfig<ConfigRecord>('uploader')).toEqual({
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
})
|
||||
expect(schemaContext.getConfig<ConfigRecord>()).toEqual({
|
||||
picBed: {
|
||||
current: 'tcyun',
|
||||
github: {
|
||||
token: 'github-token'
|
||||
}
|
||||
},
|
||||
uploader: {
|
||||
github: {
|
||||
defaultId: 'config-2'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
proxy: 'http://localhost:7890'
|
||||
}
|
||||
})
|
||||
|
||||
expect(context.getConfig('picBed.tcyun')).toEqual({
|
||||
secretId: 'existing-secret-id',
|
||||
secretKey: 'existing-secret-key'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureExpanded: vi.fn(),
|
||||
ensureHydrated: vi.fn(async () => {}),
|
||||
navigate: vi.fn(),
|
||||
refreshConfigSchema: vi.fn(),
|
||||
setHydrating: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
useSearch: () => ({
|
||||
uploader: 'tcyun',
|
||||
configId: 'config-1'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/adapters/plugins', () => ({
|
||||
pluginsAdapter: {
|
||||
refreshConfigSchema: mocks.refreshConfigSchema
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
appActions: {
|
||||
ensureHydrated: mocks.ensureHydrated
|
||||
},
|
||||
providerStoreActions: {
|
||||
ensureExpanded: mocks.ensureExpanded,
|
||||
setHydrating: mocks.setHydrating
|
||||
},
|
||||
useAppStore: {
|
||||
use: {
|
||||
appConfig: () => ({
|
||||
picBed: {
|
||||
uploader: 'tcyun'
|
||||
},
|
||||
uploader: {
|
||||
tcyun: {
|
||||
defaultId: 'config-1',
|
||||
configList: [
|
||||
{
|
||||
_id: 'config-1',
|
||||
_configName: 'Existing Config',
|
||||
_createdAt: 1700000000000,
|
||||
_updatedAt: 1700000000000,
|
||||
secretKey: 'existing-secret-key'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}),
|
||||
providers: () => [
|
||||
{
|
||||
id: 'tcyun',
|
||||
name: 'Tencent Cloud',
|
||||
visible: true,
|
||||
isDefaultUploader: true
|
||||
}
|
||||
],
|
||||
hasHydrated: () => true
|
||||
}
|
||||
},
|
||||
useProviderStore: {
|
||||
use: {
|
||||
isHydrating: () => false
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-sidebar', () => ({
|
||||
ProviderSidebar: ({
|
||||
onCreateIntent
|
||||
}: {
|
||||
onCreateIntent: (uploaderId: string) => void
|
||||
}) => (
|
||||
<button type='button' onClick={() => onCreateIntent('tcyun')}>
|
||||
Open create dialog
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-config-panel', () => ({
|
||||
ProviderConfigPanel: ({
|
||||
draftConfigMap
|
||||
}: {
|
||||
draftConfigMap: Record<string, unknown>
|
||||
}) => (
|
||||
<output data-testid='draft-config'>{JSON.stringify(draftConfigMap)}</output>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-config-name-dialog', () => ({
|
||||
ProviderConfigNameDialog: ({
|
||||
state,
|
||||
onSubmit
|
||||
}: {
|
||||
state: { name: string } | null
|
||||
onSubmit: () => Promise<void>
|
||||
}) => state
|
||||
? (
|
||||
<button type='button' onClick={async () => await onSubmit()}>
|
||||
Submit create dialog
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/main/providers/provider-delete-config-dialog', () => ({
|
||||
ProviderDeleteConfigDialog: () => null
|
||||
}))
|
||||
|
||||
import { PicGoProviders } from '@/components/main/providers/picgo-providers'
|
||||
|
||||
describe('PicGoProviders create config', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.refreshConfigSchema.mockResolvedValue([
|
||||
{
|
||||
name: 'version',
|
||||
type: 'list',
|
||||
choices: ['v4', 'v5'],
|
||||
default: 'v5',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'secretKey',
|
||||
type: 'password',
|
||||
default: '',
|
||||
required: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('requests schema-only defaults and creates an empty credential draft', async () => {
|
||||
render(<PicGoProviders />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open create dialog' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit create dialog' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.refreshConfigSchema).toHaveBeenCalledWith({
|
||||
target: 'uploader',
|
||||
uploaderName: 'tcyun',
|
||||
draftValues: {},
|
||||
schemaOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const draftMap = JSON.parse(
|
||||
screen.getByTestId('draft-config').textContent ?? '{}'
|
||||
) as Record<string, Record<string, unknown>>
|
||||
expect(draftMap.tcyun).toMatchObject({
|
||||
_configName: 'New Config',
|
||||
_isDraft: true,
|
||||
version: 'v5',
|
||||
secretKey: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,13 @@ import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { evaluatePluginConfig } from 'picgo'
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
search: {
|
||||
uploader: 'picgo-plugin-test',
|
||||
configId: 'config-1'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
@@ -15,10 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => ({
|
||||
uploader: 'picgo-plugin-test',
|
||||
configId: 'config-1'
|
||||
})
|
||||
useSearch: () => routerMocks.search
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +43,7 @@ vi.mock('@/store/providers/actions', () => ({
|
||||
}))
|
||||
|
||||
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
|
||||
import type { ProviderDraftConfigItem } from '@/components/main/providers/types'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
|
||||
import { pluginsAdapter } from '@/adapters/plugins'
|
||||
@@ -46,6 +51,11 @@ import { normalizePluginConfigSchema } from '@/components/common/normalize-plugi
|
||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
|
||||
|
||||
beforeEach(() => {
|
||||
routerMocks.search.uploader = 'picgo-plugin-test'
|
||||
routerMocks.search.configId = 'config-1'
|
||||
})
|
||||
|
||||
const baseAppConfig = {
|
||||
picBed: {
|
||||
uploader: 'picgo-plugin-test',
|
||||
@@ -88,17 +98,21 @@ const baseAppConfig = {
|
||||
needReload: false
|
||||
}
|
||||
|
||||
function setupStoreWithSavedConfig(savedConfig: Record<string, unknown>) {
|
||||
function setupStoreWithSavedConfig(
|
||||
savedConfig: Record<string, unknown>,
|
||||
schemaOverride?: unknown[]
|
||||
) {
|
||||
// Mimics what the main process sends at startup: schema evaluated with
|
||||
// empty answers, so the plugin's `default(answers)` for downstream fields
|
||||
// computes against synthAnswers defaults — NOT the user's saved values.
|
||||
// Provider-config-panel then has to issue an initial sync to bring the
|
||||
// schema in line with the saved values.
|
||||
const initialSchema = evaluatePluginConfig(
|
||||
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
|
||||
typeof evaluatePluginConfig
|
||||
>[0]
|
||||
) as unknown[]
|
||||
const initialSchema = schemaOverride ??
|
||||
evaluatePluginConfig(
|
||||
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
|
||||
typeof evaluatePluginConfig
|
||||
>[0]
|
||||
) as unknown[]
|
||||
|
||||
useAppStore.setState({
|
||||
defaultPicBed: 'picgo-plugin-test',
|
||||
@@ -182,10 +196,12 @@ function getFieldSelectTrigger(label: string) {
|
||||
return trigger as HTMLElement
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
function renderPanel(
|
||||
draftConfigMap: Record<string, ProviderDraftConfigItem | undefined> = {}
|
||||
) {
|
||||
return render(
|
||||
<ProviderConfigPanel
|
||||
draftConfigMap={{}}
|
||||
draftConfigMap={draftConfigMap}
|
||||
setDraftConfigMap={vi.fn()}
|
||||
onCreateConfigIntent={vi.fn()}
|
||||
onDeleteConfigIntent={vi.fn()}
|
||||
@@ -363,3 +379,119 @@ describe('ProviderConfigPanel — editor field rendering', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderConfigPanel — saved password rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps saved password fields masked without a reveal button', async () => {
|
||||
const passwordSchema = [
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
alias: 'Secret',
|
||||
required: true,
|
||||
message: 'Enter secret'
|
||||
}
|
||||
]
|
||||
setupStoreWithSavedConfig(
|
||||
{ secret: 'saved-secret' },
|
||||
passwordSchema
|
||||
)
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
|
||||
normalizePluginConfigSchema(passwordSchema)
|
||||
)
|
||||
|
||||
renderPanel()
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText('Secret')
|
||||
const field = label.closest('[data-slot="field"]')
|
||||
if (!field) throw new Error('No field container for password field')
|
||||
|
||||
const input = field.querySelector('input')
|
||||
expect(input?.value).toBe('saved-secret')
|
||||
expect(input?.type).toBe('password')
|
||||
expect(field.querySelector('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderConfigPanel — draft schema rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps schema refreshes isolated from persisted uploader defaults', async () => {
|
||||
const draftId = 'draft:picgo-plugin-test'
|
||||
const cachedSchema = [
|
||||
{
|
||||
name: 'version',
|
||||
type: 'list',
|
||||
choices: ['v4', 'v5'],
|
||||
default: 'v5',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
alias: 'Secret',
|
||||
default: 'persisted-secret',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
const cleanSchema = [
|
||||
cachedSchema[0],
|
||||
{
|
||||
...cachedSchema[1],
|
||||
default: ''
|
||||
}
|
||||
]
|
||||
|
||||
setupStoreWithSavedConfig(
|
||||
{
|
||||
version: 'v5',
|
||||
secret: 'persisted-secret'
|
||||
},
|
||||
cachedSchema
|
||||
)
|
||||
routerMocks.search.configId = draftId
|
||||
vi.mocked(pluginsAdapter.refreshConfigSchema).mockResolvedValue(
|
||||
normalizePluginConfigSchema(cleanSchema)
|
||||
)
|
||||
|
||||
renderPanel({
|
||||
'picgo-plugin-test': {
|
||||
_id: draftId,
|
||||
_configName: 'New Config',
|
||||
_createdAt: 1700000000001,
|
||||
_updatedAt: 1700000000001,
|
||||
_isDraft: true,
|
||||
version: 'v5',
|
||||
secret: ''
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pluginsAdapter.refreshConfigSchema).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: 'uploader',
|
||||
uploaderName: 'picgo-plugin-test',
|
||||
schemaOnly: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText('Secret')
|
||||
const field = label.closest('[data-slot="field"]')
|
||||
if (!field) throw new Error('No field container for draft password field')
|
||||
|
||||
const input = field.querySelector('input')
|
||||
expect(input?.value).toBe('')
|
||||
expect(input?.type).toBe('password')
|
||||
expect(field.querySelector('button')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,88 @@ describe('SchemaFormFields editor field', () => {
|
||||
|
||||
})
|
||||
|
||||
describe('SchemaFormFields password field', () => {
|
||||
const passwordSchema: ProviderPluginConfig[] = [
|
||||
{
|
||||
name: 'secret',
|
||||
type: 'password',
|
||||
required: true,
|
||||
alias: 'Secret'
|
||||
}
|
||||
]
|
||||
|
||||
it('allows password reveal by default', () => {
|
||||
renderSchema(passwordSchema, { secret: 'saved-secret' })
|
||||
|
||||
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
|
||||
expect(input.type).toBe('password')
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(input.type).toBe('text')
|
||||
})
|
||||
|
||||
it('keeps the password masked and removes the reveal button when disabled', () => {
|
||||
const onValueChange = vi.fn()
|
||||
render(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal={false}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('saved-secret') as HTMLInputElement
|
||||
expect(input.type).toBe('password')
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'replacement-secret' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('secret', 'replacement-secret')
|
||||
})
|
||||
|
||||
it('clears visible password state when reveal becomes disabled', () => {
|
||||
const onValueChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('text')
|
||||
|
||||
rerender(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal={false}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('password')
|
||||
|
||||
rerender(
|
||||
<SchemaFormFields
|
||||
schema={passwordSchema}
|
||||
values={{ secret: 'saved-secret' }}
|
||||
allowPasswordReveal
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
(screen.getByDisplayValue('saved-secret') as HTMLInputElement).type
|
||||
).toBe('password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SchemaFormFields unknown field type', () => {
|
||||
it('does not render any input control for an unrecognized type, label only', () => {
|
||||
renderSchema([
|
||||
|
||||
@@ -25,8 +25,7 @@ const defaultWebPreferences = {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegrationInWorker: false,
|
||||
backgroundThrottling: false
|
||||
nodeIntegrationInWorker: false
|
||||
}
|
||||
|
||||
const handleWindowParams = (windowURL: string) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
|
||||
import { dialog } from 'electron'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { IWindowList } from '#/types/enum'
|
||||
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||
|
||||
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
|
||||
|
||||
@@ -210,7 +211,10 @@ pluginsRouter
|
||||
} else if (payload.target === 'uploader') {
|
||||
const handler = picgo.helper.uploader.get(payload.uploaderName)
|
||||
if (handler?.config) {
|
||||
rawSchema = handler.config(picgo)
|
||||
const configContext = payload.schemaOnly
|
||||
? createSchemaOnlyUploaderContext(picgo, payload.uploaderName)
|
||||
: picgo
|
||||
rawSchema = handler.config(configContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +239,12 @@ pluginsRouter
|
||||
type IRefreshConfigSchemaArgs =
|
||||
| { target: 'plugin', pluginFullName: string, draftValues?: Record<string, unknown> }
|
||||
| { target: 'transformer', pluginFullName: string, draftValues?: Record<string, unknown> }
|
||||
| { target: 'uploader', uploaderName: string, draftValues?: Record<string, unknown> }
|
||||
| {
|
||||
target: 'uploader'
|
||||
uploaderName: string
|
||||
draftValues?: Record<string, unknown>
|
||||
schemaOnly?: boolean
|
||||
}
|
||||
|
||||
export {
|
||||
pluginsRouter
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
interface ConfigReadableContext {
|
||||
getConfig<T>(name?: string): T
|
||||
}
|
||||
|
||||
type ConfigRecord = Record<string, unknown>
|
||||
|
||||
function isConfigRecord(value: unknown): value is ConfigRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function omitUploaderConfig(
|
||||
value: unknown,
|
||||
uploaderName: string
|
||||
): unknown {
|
||||
if (!isConfigRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const nextValue = { ...value }
|
||||
delete nextValue[uploaderName]
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function sanitizeFullConfig(value: unknown, uploaderName: string): unknown {
|
||||
if (!isConfigRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const nextValue = { ...value }
|
||||
|
||||
if ('picBed' in value) {
|
||||
nextValue.picBed = omitUploaderConfig(value.picBed, uploaderName)
|
||||
}
|
||||
|
||||
if ('uploader' in value) {
|
||||
nextValue.uploader = omitUploaderConfig(value.uploader, uploaderName)
|
||||
}
|
||||
|
||||
return nextValue
|
||||
}
|
||||
|
||||
export function createSchemaOnlyUploaderContext<T extends ConfigReadableContext>(
|
||||
context: T,
|
||||
uploaderName: string
|
||||
): T {
|
||||
const hiddenConfigPaths = [
|
||||
`picBed.${uploaderName}`,
|
||||
`uploader.${uploaderName}`
|
||||
]
|
||||
|
||||
const getConfig = <V>(name?: string): V => {
|
||||
if (
|
||||
name &&
|
||||
hiddenConfigPaths.some(
|
||||
(configPath) => name === configPath || name.startsWith(`${configPath}.`)
|
||||
)
|
||||
) {
|
||||
return undefined as V
|
||||
}
|
||||
|
||||
if (name === 'picBed' || name === 'uploader') {
|
||||
return omitUploaderConfig(
|
||||
context.getConfig<unknown>(name),
|
||||
uploaderName
|
||||
) as V
|
||||
}
|
||||
|
||||
if (name === undefined) {
|
||||
return sanitizeFullConfig(
|
||||
context.getConfig<unknown>(),
|
||||
uploaderName
|
||||
) as V
|
||||
}
|
||||
|
||||
return context.getConfig<V>(name)
|
||||
}
|
||||
|
||||
return new Proxy(context, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'getConfig') {
|
||||
return getConfig
|
||||
}
|
||||
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { normalizePluginConfigSchema } from '@/components/common/normalize-plugi
|
||||
export type IRefreshConfigSchemaArgs =
|
||||
| { target: 'plugin', pluginFullName: string, draftValues: Record<string, unknown> }
|
||||
| { target: 'transformer', pluginFullName: string, draftValues: Record<string, unknown> }
|
||||
| { target: 'uploader', uploaderName: string, draftValues: Record<string, unknown> }
|
||||
| {
|
||||
target: 'uploader'
|
||||
uploaderName: string
|
||||
draftValues: Record<string, unknown>
|
||||
schemaOnly?: boolean
|
||||
}
|
||||
|
||||
interface PluginInstallResult {
|
||||
success: boolean
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SchemaFormFieldsProps {
|
||||
schema: ProviderPluginConfig[]
|
||||
values: SchemaFormValues
|
||||
fieldErrors?: SchemaFieldErrorMap
|
||||
allowPasswordReveal?: boolean
|
||||
onValueChange: (name: string, value: unknown) => void
|
||||
}
|
||||
|
||||
@@ -49,6 +50,14 @@ interface CheckboxFieldProps {
|
||||
onValueChange: (name: string, value: unknown) => void
|
||||
}
|
||||
|
||||
interface PasswordFieldProps {
|
||||
field: ProviderPluginConfig
|
||||
selectedValue: unknown
|
||||
isInvalid: boolean
|
||||
allowPasswordReveal: boolean
|
||||
onValueChange: (name: string, value: unknown) => void
|
||||
}
|
||||
|
||||
function renderSanitizedTips(markdown: string) {
|
||||
const parsed = marked.parse(markdown)
|
||||
const html = typeof parsed === "string" ? parsed : markdown
|
||||
@@ -151,16 +160,51 @@ function CheckboxField({
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordField({
|
||||
field,
|
||||
selectedValue,
|
||||
isInvalid,
|
||||
allowPasswordReveal,
|
||||
onValueChange,
|
||||
}: PasswordFieldProps) {
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={String(selectedValue ?? "")}
|
||||
type={isPasswordVisible ? "text" : "password"}
|
||||
placeholder={field.message || field.name}
|
||||
className={allowPasswordReveal ? "pr-10" : undefined}
|
||||
aria-invalid={isInvalid}
|
||||
onChange={(event) => onValueChange(field.name, event.target.value)}
|
||||
/>
|
||||
{allowPasswordReveal && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||
onClick={() => setIsPasswordVisible((prev) => !prev)}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchemaFormFields({
|
||||
schema,
|
||||
values,
|
||||
fieldErrors = {},
|
||||
allowPasswordReveal = true,
|
||||
onValueChange,
|
||||
}: SchemaFormFieldsProps) {
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{schema.map((field) => {
|
||||
@@ -169,7 +213,6 @@ export function SchemaFormFields({
|
||||
const optionValueMap = new Map(
|
||||
choices.map((choice) => [String(choice.value), choice.value] as const)
|
||||
)
|
||||
const isPasswordVisible = Boolean(visiblePasswords[field.name])
|
||||
const fieldError = fieldErrors[field.name]
|
||||
const isInvalid = Boolean(fieldError)
|
||||
|
||||
@@ -230,34 +273,14 @@ export function SchemaFormFields({
|
||||
)}
|
||||
|
||||
{field.type === "password" && (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
type={isPasswordVisible ? "text" : "password"}
|
||||
placeholder={field.message || field.name}
|
||||
className="pr-10"
|
||||
aria-invalid={isInvalid}
|
||||
onChange={(event) => onValueChange(field.name, event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||
onClick={() => {
|
||||
setVisiblePasswords((prev) => ({
|
||||
...prev,
|
||||
[field.name]: !prev[field.name],
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<PasswordField
|
||||
key={`${field.name}:${allowPasswordReveal ? "reveal" : "locked"}`}
|
||||
field={field}
|
||||
selectedValue={value}
|
||||
isInvalid={isInvalid}
|
||||
allowPasswordReveal={allowPasswordReveal}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "list" && (
|
||||
|
||||
@@ -71,7 +71,8 @@ export function UploaderSwitcher({
|
||||
className="group h-10 cursor-pointer bg-card/80 px-4 backdrop-blur transition-all hover:border-primary/30"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="size-2 animate-pulse rounded-full bg-green-500" />
|
||||
{/* 刻意不加 animate-pulse:2s infinite 会让主窗口空闲时也持续产出合成帧,把周围几层 backdrop-blur 一起钉在满帧率上重算,GPU 进程无法进入空闲。静态圆点表意相同。 */}
|
||||
<span className="size-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
{current.providerName}
|
||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { pluginsAdapter } from "@/adapters/plugins"
|
||||
import { appActions, providerStoreActions, useAppStore, useProviderStore } from "@/store"
|
||||
import { ProviderConfigNameDialog } from "./provider-config-name-dialog"
|
||||
import { ProviderConfigPanel } from "./provider-config-panel"
|
||||
@@ -35,7 +36,6 @@ export function PicGoProviders() {
|
||||
|
||||
const appConfig = useAppStore.use.appConfig()
|
||||
const providers = useAppStore.use.providers()
|
||||
const providerSchemas = useAppStore.use.providerSchemas()
|
||||
const hasHydrated = useAppStore.use.hasHydrated()
|
||||
const isLoadingUploaders = useProviderStore.use.isHydrating()
|
||||
|
||||
@@ -167,14 +167,12 @@ export function PicGoProviders() {
|
||||
|
||||
const handleCreateConfigDraft = async (uploaderId: string, configName: string) => {
|
||||
try {
|
||||
const resolvedSchema =
|
||||
(await providerStoreActions.ensureSchema(uploaderId)).config ??
|
||||
providerSchemas[uploaderId]?.config
|
||||
|
||||
if (!resolvedSchema) {
|
||||
toast.error(t("FAILED"))
|
||||
return
|
||||
}
|
||||
const resolvedSchema = await pluginsAdapter.refreshConfigSchema({
|
||||
target: "uploader",
|
||||
uploaderName: uploaderId,
|
||||
draftValues: {},
|
||||
schemaOnly: true,
|
||||
})
|
||||
|
||||
const now = Date.now()
|
||||
const draftId = createDraftConfigId(uploaderId)
|
||||
|
||||
@@ -300,9 +300,11 @@ export function ProviderConfigPanel({
|
||||
</div>
|
||||
|
||||
<ProviderFormFields
|
||||
key={`${selectedUploaderId}:${selectedConfig._id}`}
|
||||
schema={schema}
|
||||
values={formValues}
|
||||
fieldErrors={fieldErrors}
|
||||
allowPasswordReveal={isDraftSelected}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
|
||||
@@ -335,4 +337,3 @@ export function ProviderConfigPanel({
|
||||
</AppMainCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,9 +78,10 @@ export function useProviderConfigForm({
|
||||
target: "uploader",
|
||||
uploaderName: refreshTarget.uploaderName,
|
||||
draftValues,
|
||||
schemaOnly: isDraftSelected,
|
||||
})
|
||||
},
|
||||
[]
|
||||
[isDraftSelected]
|
||||
)
|
||||
|
||||
// Re-hydrate when the persisted config's _updatedAt changes (initial load,
|
||||
@@ -113,6 +114,9 @@ export function useProviderConfigForm({
|
||||
.then((nextSchema) => {
|
||||
if (cancelled) return
|
||||
setLiveSchema(nextSchema)
|
||||
if (isDraftSelected) {
|
||||
setValues(buildFormValues(nextSchema, selectedConfig))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
|
||||
Vendored
+1
-1
@@ -144,7 +144,7 @@ interface IBrowserWindowOptions {
|
||||
nodeIntegration: boolean,
|
||||
nodeIntegrationInWorker: boolean,
|
||||
contextIsolation: boolean,
|
||||
backgroundThrottling: boolean
|
||||
backgroundThrottling?: boolean
|
||||
webSecurity?: boolean
|
||||
},
|
||||
vibrancy?: string | any,
|
||||
|
||||
Reference in New Issue
Block a user