Compare commits

...
9 Commits
Author SHA1 Message Date
PiEgg d5543073ea Feature: rebuild picgo.app when a release is published (#1444)
*  Feature: rebuild picgo.app when a release is published

The download page on picgo.app reads the installer list from the GitHub API at
build time, so a new release does not appear there until the site rebuilds.

Fires on `release: published` rather than on the tag push, because releases are
created as drafts here -- at tag time `releases/latest` still points at the
previous version, so the rebuild would pick up stale installers.

Needs a HOMEPAGE_DISPATCH_TOKEN secret with workflow scope on the homepage
repository; the existing GH_TOKEN is not reused so the two can be revoked
independently.

* 📦 Chore: allow builds for the native dependencies

pnpm 11 gates build scripts behind an explicit allowlist, so a fresh install
stopped partway and asked for `pnpm approve-builds`, which breaks any
non-interactive run including the husky pre-commit hook.

Records the approvals that were already implied by onlyBuiltDependencies. The
spacing change in supportedArchitectures is pnpm rewriting the file.
2026-09-01 20:01:23 +08:00
PiEgg 07ec7068a5 ⬆️ Upgrade: bump @picgo/bump-version to 3.0.0
3.0.0 ships as ESM and renamed the configs it exposes to consumers, so
the cz-customizable and commitlint paths gain their .cjs extensions.

It also stopped shipping contributor tooling as runtime dependencies,
which this repo was relying on: @commitlint/cli reached the commitlint
script only as a transitive dependency of bump-version 2, so the upgrade
would have removed the binary the commit-msg hook needs. It is now a
direct devDependency.

Drops the "husky" field, which has been dead config since c84d542 moved
this repo to husky 9 — v9 reads .husky/ and ignores it entirely. The
circular-dependency check it used to declare has been unhooked ever
since, so lint:dpdm moves to .husky/pre-commit where it actually runs.
Confirmed it both passes on the current tree and fails on a deliberately
introduced cycle, so it is a real gate rather than a no-op.
2026-08-22 22:58:01 +08:00
PiEgg d7d2c22994 📝 Docs: add AI-friendly section for skills and dsh plugin 2026-08-22 13:09:52 +08:00
PiEgg e21e92c0d3 📝 Docs: replace Warp sponsorship with NocoBase 2026-08-17 14:59:27 +08:00
PiEgg a0d5dfaed1 🐛 Fix(gui): make backgroundThrottling optional in window options type
#1435 removed `backgroundThrottling: false` from `defaultWebPreferences`, but
`IBrowserWindowOptions.webPreferences` still declared the field as required, so
`tsc` failed on all five window definitions.

Mark it optional rather than dropping it: omitting the field keeps Electron's
default (throttle when hidden), which is what #1435 intended, while individual
windows can still opt out explicitly if they ever need full-speed background
rendering.
2026-08-17 14:59:09 +08:00
WayneVoyagerandWayneVoyager 45fd078e4e 🐛 Fix(gui): 移除 backgroundThrottling: false 修复 macOS GPU 进程空转 (#1435)
Co-authored-by: WayneVoyager <hanghaiwang@tencent.com>
2026-08-14 16:13:01 +08:00
PiEgg 4676326eb8 🎉 Release: v3.0.2 2026-08-14 15:49:03 +08:00
PiEgg 33bbc9a512 Perf(gui): stop idle GPU churn from uploader status dot (#1436) 2026-08-14 15:45:41 +08:00
PiEgg 77f1ed16b7 🐛 Fix(gui): protect persisted provider credentials 2026-07-21 14:21:10 +08:00
22 changed files with 1298 additions and 851 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Notify Homepage
# Releases are created as drafts, so a tag push is too early -- the download
# page reads releases/latest, which only reflects a release once it is
# published. Firing on `published` also covers the case where a draft sits for
# days before going live.
on:
release:
types: [published]
workflow_dispatch:
jobs:
rebuild-homepage:
name: Rebuild picgo.app
runs-on: ubuntu-latest
steps:
- name: Trigger homepage deploy
env:
GH_TOKEN: ${{ secrets.HOMEPAGE_DISPATCH_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::HOMEPAGE_DISPATCH_TOKEN is not set"
exit 1
fi
# The homepage builds its download list from the GitHub API at build
# time, so it needs a rebuild to pick up a new release.
gh workflow run deploy.yml \
--repo PicGo/PicGo-Homepage \
--ref production
echo "Requested a production rebuild of picgo.app"
+1
View File
@@ -1 +1,2 @@
pnpm check
pnpm run lint:dpdm
+14
View File
@@ -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)
+19 -4
View File
@@ -1,12 +1,11 @@
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<a href="https://go.warp.dev/picgo">
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
<a href="https://www.nocobase.com/?utm_source=picgo">
<img alt="NocoBase sponsorship" width="400" src="https://static-docs.nocobase.com/Logo-Black.png">
</a>
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
### [NocoBase, AI + No-Code Build reliable business systems](https://www.nocobase.com/?utm_source=picgo)
</div>
@@ -92,6 +91,20 @@ PicGo is built around a fast, low-friction image upload experience:
- **Even more possibilities**: image compression, watermarking, renaming, Markdown image migration, and more.
- Explore plugins: [Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
### 🤖 AI-friendly
More and more writing is handed off to AI. But the screenshots and charts it produces sit on your disk as `![](./chart.png)`, and they need to become real links before you publish. PicGo lets AI upload them too:
- **[PicGo Skills](https://github.com/PicGo/skills)**: official Agent Skills that teach AI when and how to upload. Works with any AI tool that supports the skills format.
```bash
npx skills@latest add PicGo/skills
```
- **[DeepSeek Harness plugin](https://github.com/PicGo/dsh-plugin)**: install it in [dsh](https://github.com/deepseek-ai/deepseek-harness) and your agent can upload to your image host on its own.
```bash
dsh plugin --profile web add @picgo/dsh-plugin
```
Both reuse the image hosts and plugins you already configured in PicGo—nothing to set up twice. Read more in [this post](https://picgo.app/blog/2026/picgo-deepseek-harness-plugin/).
### 🛠 Developer-friendly
- **HTTP API**: upload via HTTP requests (v2.2.0+), making it easy to integrate with other tools.
- **Open source**: fully open-source and transparent.
@@ -163,6 +176,8 @@ Electron binaries are stored under `~/.electron/`. If you need to refresh them,
- [vs-picgo](https://github.com/PicGo/vs-picgo): PicGo for VS Code.
- [flutter-picgo](https://github.com/PicGo/flutter-picgo): mobile app (Android & iOS).
- [PicHoro](https://github.com/Kuingsmile/PicHoro): another mobile app compatible with PicGo config (Android only for now).
- [skills](https://github.com/PicGo/skills): official Agent Skills that teach AI to upload with PicGo.
- [dsh-plugin](https://github.com/PicGo/dsh-plugin): PicGo plugin for DeepSeek Harness.
## Sponsorship
+19 -4
View File
@@ -1,12 +1,11 @@
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<a href="https://go.warp.dev/picgo">
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
<a href="https://www.nocobase.com/?utm_source=picgo">
<img alt="NocoBase sponsorship" width="400" src="https://static-docs.nocobase.com/Logo-Black.png">
</a>
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
### [NocoBase, AI + No-Code Build reliable business systems](https://www.nocobase.com/?utm_source=picgo)
</div>
@@ -92,6 +91,20 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
- **更多可能**:支持图片压缩、水印、重命名、Markdown 图片迁移等功能插件。
- 探索更多插件:[Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
### 🤖 AI 友好
写文档的活儿越来越多地交给了 AI。但 AI 生成的截图、图表存在本地,`![](./chart.png)`,发布前需要转成可访问链接。PicGo 让 AI 也能轻松传图:
- **[PicGo Skills](https://github.com/PicGo/skills)**:官方 Agent Skills 集合,教会 AI 什么时候该传图、怎么传。适用于任何支持 skills 格式的 AI 工具。
```bash
npx skills@latest add PicGo/skills
```
- **[DeepSeek Harness 插件](https://github.com/PicGo/dsh-plugin)**:在 [dsh](https://github.com/deepseek-ai/deepseek-harness) 里装上它,AI 就能自己把图传到你的图床。
```bash
dsh plugin --profile web add @picgo/dsh-plugin
```
两者都会复用你在 PicGo 里已经配好的图床和插件,不用重新配置一遍。详见[这篇介绍](https://picgo.app/blog/2026/picgo-deepseek-harness-plugin/)。
### 🛠 开发者友好
- **HTTP API**:支持通过 HTTP 请求调用 PicGo 上传 (v2.2.0+),方便与其他工具集成。
- **开源透明**:代码完全开源,安全可靠。
@@ -163,6 +176,8 @@ pnpm run build
- [vs-picgo](https://github.com/PicGo/vs-picgo)PicGo 的 VS Code 版。
- [flutter-picgo](https://github.com/PicGo/flutter-picgo)PicGo 的手机版 App(支持 Android 和 iOS )。
- [PicHoro](https://github.com/Kuingsmile/PicHoro):另一款支持 PicGo 配置的手机版 App(暂时只支持 Android)。
- [skills](https://github.com/PicGo/skills)PicGo 官方 Agent Skills,让 AI 学会用 PicGo 传图。
- [dsh-plugin](https://github.com/PicGo/dsh-plugin)PicGo 的 DeepSeek Harness 插件。
## 赞助
+5 -9
View File
@@ -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.",
@@ -99,9 +99,10 @@
"devDependencies": {
"@aws-sdk/client-s3": "^3.276.0",
"@aws-sdk/lib-storage": "^3.276.0",
"@commitlint/cli": "^21.2.2",
"@eslint/js": "^9.39.1",
"@molunerfinn/vite-plugin-electron-renderer": "^0.14.7",
"@picgo/bump-version": "^2.0.0",
"@picgo/bump-version": "^3.0.0",
"@stylistic/eslint-plugin": "^5.6.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
@@ -160,7 +161,7 @@
},
"commitlint": {
"extends": [
"./node_modules/@picgo/bump-version/commitlint-picgo"
"./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"
]
},
"config": {
@@ -168,12 +169,7 @@
"path": "./node_modules/cz-customizable"
},
"cz-customizable": {
"config": "./node_modules/@picgo/bump-version/.cz-config.js"
}
},
"husky": {
"hooks": {
"commit-msg": "npm run lint:dpdm && commitlint -E HUSKY_GIT_PARAMS"
"config": "./node_modules/@picgo/bump-version/.cz-config.cjs"
}
}
}
+522 -769
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,3 +1,11 @@
allowBuilds:
core-js: true
electron: true
electron-winstaller: true
esbuild: true
msw: true
vue-demi: true
onlyBuiltDependencies:
- core-js
- ejs
@@ -8,5 +16,5 @@ onlyBuiltDependencies:
- vue-demi
supportedArchitectures:
cpu: [x64, arm64]
os: [current]
cpu: [ x64, arm64 ]
os: [ current ]
@@ -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([
+1 -2
View File
@@ -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) => {
+11 -2
View File
@@ -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)
}
})
}
+6 -1
View File
@@ -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-pulse2s 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
+2 -1
View File
@@ -144,7 +144,8 @@ interface IBrowserWindowOptions {
nodeIntegration: boolean,
nodeIntegrationInWorker: boolean,
contextIsolation: boolean,
backgroundThrottling: boolean
// 不传即沿用 Electron 默认值(true,窗口不可见时降频);仅在某个窗口确实需要后台全速渲染时才显式关闭。
backgroundThrottling?: boolean
webSecurity?: boolean
},
vibrancy?: string | any,