mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-09-25 03:29:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24af3006f | ||
|
|
99b0331561 | ||
|
|
d5543073ea | ||
|
|
07ec7068a5 | ||
|
|
d7d2c22994 | ||
|
|
e21e92c0d3 | ||
|
|
a0d5dfaed1 | ||
|
|
45fd078e4e | ||
|
|
4676326eb8 | ||
|
|
33bbc9a512 | ||
|
|
77f1ed16b7 |
@@ -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 +1,2 @@
|
|||||||
pnpm check
|
pnpm check
|
||||||
|
pnpm run lint:dpdm
|
||||||
|
|||||||
@@ -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)
|
## :tada: 3.0.1 (2026-07-12)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
<div align="center" markdown="1">
|
<div align="center">
|
||||||
<sup>Special thanks to:</sup>
|
<h3>Special thanks to</h3>
|
||||||
<br>
|
<table>
|
||||||
<a href="https://go.warp.dev/picgo">
|
<tr>
|
||||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
|
<td align="center" valign="middle" width="50%">
|
||||||
|
<a href="https://modelflare.dev/sign-up?partner=1GIMVVBLWP1V">
|
||||||
|
<img alt="Modelflare sponsorship" width="400" src="https://pics.picgo.app/m/ef1a160c-c9ce-4605-9b9c-b2d355cd2de4.png">
|
||||||
</a>
|
</a>
|
||||||
|
<h3><a href="https://modelflare.dev/sign-up?partner=1GIMVVBLWP1V">Modelflare</a></h3>
|
||||||
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
|
<p>Modelflare: Full strength, stable, nothing watered down. Global SOTA models at a lower cost.</p>
|
||||||
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
|
</td>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
</div>
|
<a href="https://castaly.modelflare.dev/sign-up?partner=1GIMVVBLWP1V">
|
||||||
|
<img alt="Castaly sponsorship" width="400" src="https://pics.picgo.app/m/284def41-2f23-47e6-9d64-7017d386a221.png">
|
||||||
<div align="center" markdown="1">
|
</a>
|
||||||
<sup>Sponsored by:</sup>
|
<h3><a href="https://castaly.modelflare.dev/sign-up?partner=1GIMVVBLWP1V">Castaly</a></h3>
|
||||||
<br>
|
<p>Castaly: Full strength, no upscaling. 40+ image and video models, including NSFW.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
|
<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>
|
||||||
|
<h3><a href="https://www.nocobase.com/?utm_source=picgo">NocoBase</a></h3>
|
||||||
|
<p>AI + No-Code Build reliable business systems</p>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
<a href="https://console.neon.tech/app/?promo=PicGo">
|
<a href="https://console.neon.tech/app/?promo=PicGo">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||||
@@ -20,9 +33,11 @@
|
|||||||
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
<h3><a href="https://console.neon.tech/app/?promo=PicGo">Neon</a></h3>
|
||||||
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
|
<p>Fast Postgres Databases for Teams and Agents</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +107,20 @@ PicGo is built around a fast, low-friction image upload experience:
|
|||||||
- **Even more possibilities**: image compression, watermarking, renaming, Markdown image migration, and more.
|
- **Even more possibilities**: image compression, watermarking, renaming, Markdown image migration, and more.
|
||||||
- Explore plugins: [Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
|
- 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 ``, 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
|
### 🛠 Developer-friendly
|
||||||
- **HTTP API**: upload via HTTP requests (v2.2.0+), making it easy to integrate with other tools.
|
- **HTTP API**: upload via HTTP requests (v2.2.0+), making it easy to integrate with other tools.
|
||||||
- **Open source**: fully open-source and transparent.
|
- **Open source**: fully open-source and transparent.
|
||||||
@@ -163,6 +192,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.
|
- [vs-picgo](https://github.com/PicGo/vs-picgo): PicGo for VS Code.
|
||||||
- [flutter-picgo](https://github.com/PicGo/flutter-picgo): mobile app (Android & iOS).
|
- [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).
|
- [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
|
## Sponsorship
|
||||||
|
|
||||||
|
|||||||
+48
-17
@@ -1,18 +1,31 @@
|
|||||||
<div align="center" markdown="1">
|
<div align="center">
|
||||||
<sup>Special thanks to:</sup>
|
<h3>Special thanks to</h3>
|
||||||
<br>
|
<table>
|
||||||
<a href="https://go.warp.dev/picgo">
|
<tr>
|
||||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-03.png">
|
<td align="center" valign="middle" width="50%">
|
||||||
|
<a href="https://modelflare.dev/zh/sign-up?partner=1GIMVVBLWP1V">
|
||||||
|
<img alt="Modelflare sponsorship" width="400" src="https://pics.picgo.app/m/ef1a160c-c9ce-4605-9b9c-b2d355cd2de4.png">
|
||||||
</a>
|
</a>
|
||||||
|
<h3><a href="https://modelflare.dev/zh/sign-up?partner=1GIMVVBLWP1V">Modelflare</a></h3>
|
||||||
### [Warp, the intelligent terminal for developers](https://go.warp.dev/picgo)
|
<p>Modelflare: 满血稳定不掺水,低成本享受全球 SOTA 模型。</p>
|
||||||
[Available for macOS, Linux, & Windows](https://go.warp.dev/picgo)<br>
|
</td>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
</div>
|
<a href="https://castaly.modelflare.dev/zh/sign-up?partner=1GIMVVBLWP1V">
|
||||||
|
<img alt="Castaly sponsorship" width="400" src="https://pics.picgo.app/m/284def41-2f23-47e6-9d64-7017d386a221.png">
|
||||||
<div align="center" markdown="1">
|
</a>
|
||||||
<sup>Sponsored by:</sup>
|
<h3><a href="https://castaly.modelflare.dev/zh/sign-up?partner=1GIMVVBLWP1V">Castaly</a></h3>
|
||||||
<br>
|
<p>Castaly: 满血不超分,含 NSFW 在内 40+图片视频模型。</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
|
<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>
|
||||||
|
<h3><a href="https://www.nocobase.com/?utm_source=picgo">NocoBase</a></h3>
|
||||||
|
<p>AI + No-Code Build reliable business systems</p>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle" width="50%">
|
||||||
<a href="https://console.neon.tech/app/?promo=PicGo">
|
<a href="https://console.neon.tech/app/?promo=PicGo">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||||
@@ -20,9 +33,11 @@
|
|||||||
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
<h3><a href="https://console.neon.tech/app/?promo=PicGo">Neon</a></h3>
|
||||||
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
|
<p>Fast Postgres Databases for Teams and Agents</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -92,6 +107,20 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
|
|||||||
- **更多可能**:支持图片压缩、水印、重命名、Markdown 图片迁移等功能插件。
|
- **更多可能**:支持图片压缩、水印、重命名、Markdown 图片迁移等功能插件。
|
||||||
- 探索更多插件:[Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
|
- 探索更多插件:[Awesome-PicGo](https://github.com/PicGo/Awesome-PicGo)
|
||||||
|
|
||||||
|
### 🤖 AI 友好
|
||||||
|
写文档的活儿越来越多地交给了 AI。但 AI 生成的截图、图表存在本地,``,发布前需要转成可访问链接。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+),方便与其他工具集成。
|
- **HTTP API**:支持通过 HTTP 请求调用 PicGo 上传 (v2.2.0+),方便与其他工具集成。
|
||||||
- **开源透明**:代码完全开源,安全可靠。
|
- **开源透明**:代码完全开源,安全可靠。
|
||||||
@@ -163,6 +192,8 @@ pnpm run build
|
|||||||
- [vs-picgo](https://github.com/PicGo/vs-picgo):PicGo 的 VS Code 版。
|
- [vs-picgo](https://github.com/PicGo/vs-picgo):PicGo 的 VS Code 版。
|
||||||
- [flutter-picgo](https://github.com/PicGo/flutter-picgo):PicGo 的手机版 App(支持 Android 和 iOS )。
|
- [flutter-picgo](https://github.com/PicGo/flutter-picgo):PicGo 的手机版 App(支持 Android 和 iOS )。
|
||||||
- [PicHoro](https://github.com/Kuingsmile/PicHoro):另一款支持 PicGo 配置的手机版 App(暂时只支持 Android)。
|
- [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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picgo",
|
"name": "picgo",
|
||||||
"version": "3.0.1",
|
"version": "3.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist_electron/main/index.js",
|
"main": "dist_electron/main/index.js",
|
||||||
"description": "A powerful & simple image uploader for creators.",
|
"description": "A powerful & simple image uploader for creators.",
|
||||||
@@ -99,9 +99,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.276.0",
|
"@aws-sdk/client-s3": "^3.276.0",
|
||||||
"@aws-sdk/lib-storage": "^3.276.0",
|
"@aws-sdk/lib-storage": "^3.276.0",
|
||||||
|
"@commitlint/cli": "^21.2.2",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@molunerfinn/vite-plugin-electron-renderer": "^0.14.7",
|
"@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",
|
"@stylistic/eslint-plugin": "^5.6.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
},
|
},
|
||||||
"commitlint": {
|
"commitlint": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"./node_modules/@picgo/bump-version/commitlint-picgo"
|
"./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -168,12 +169,7 @@
|
|||||||
"path": "./node_modules/cz-customizable"
|
"path": "./node_modules/cz-customizable"
|
||||||
},
|
},
|
||||||
"cz-customizable": {
|
"cz-customizable": {
|
||||||
"config": "./node_modules/@picgo/bump-version/.cz-config.js"
|
"config": "./node_modules/@picgo/bump-version/.cz-config.cjs"
|
||||||
}
|
|
||||||
},
|
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"commit-msg": "npm run lint:dpdm && commitlint -E HUSKY_GIT_PARAMS"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+522
-769
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,3 +1,11 @@
|
|||||||
|
allowBuilds:
|
||||||
|
core-js: true
|
||||||
|
electron: true
|
||||||
|
electron-winstaller: true
|
||||||
|
esbuild: true
|
||||||
|
msw: true
|
||||||
|
vue-demi: true
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- core-js
|
- core-js
|
||||||
- ejs
|
- ejs
|
||||||
@@ -8,5 +16,5 @@ onlyBuiltDependencies:
|
|||||||
- vue-demi
|
- vue-demi
|
||||||
|
|
||||||
supportedArchitectures:
|
supportedArchitectures:
|
||||||
cpu: [x64, arm64]
|
cpu: [ x64, arm64 ]
|
||||||
os: [current]
|
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 { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
import { evaluatePluginConfig } from 'picgo'
|
import { evaluatePluginConfig } from 'picgo'
|
||||||
|
|
||||||
|
const routerMocks = vi.hoisted(() => ({
|
||||||
|
search: {
|
||||||
|
uploader: 'picgo-plugin-test',
|
||||||
|
configId: 'config-1'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: () => ({ t: (key: string) => key })
|
useTranslation: () => ({ t: (key: string) => key })
|
||||||
}))
|
}))
|
||||||
@@ -15,10 +22,7 @@ vi.mock('@tanstack/react-router', async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useNavigate: () => vi.fn(),
|
useNavigate: () => vi.fn(),
|
||||||
useSearch: () => ({
|
useSearch: () => routerMocks.search
|
||||||
uploader: 'picgo-plugin-test',
|
|
||||||
configId: 'config-1'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,6 +43,7 @@ vi.mock('@/store/providers/actions', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
|
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
|
||||||
|
import type { ProviderDraftConfigItem } from '@/components/main/providers/types'
|
||||||
import { useAppStore } from '@/store/app-store'
|
import { useAppStore } from '@/store/app-store'
|
||||||
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
|
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
|
||||||
import { pluginsAdapter } from '@/adapters/plugins'
|
import { pluginsAdapter } from '@/adapters/plugins'
|
||||||
@@ -46,6 +51,11 @@ import { normalizePluginConfigSchema } from '@/components/common/normalize-plugi
|
|||||||
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
|
||||||
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
|
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
routerMocks.search.uploader = 'picgo-plugin-test'
|
||||||
|
routerMocks.search.configId = 'config-1'
|
||||||
|
})
|
||||||
|
|
||||||
const baseAppConfig = {
|
const baseAppConfig = {
|
||||||
picBed: {
|
picBed: {
|
||||||
uploader: 'picgo-plugin-test',
|
uploader: 'picgo-plugin-test',
|
||||||
@@ -88,13 +98,17 @@ const baseAppConfig = {
|
|||||||
needReload: false
|
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
|
// Mimics what the main process sends at startup: schema evaluated with
|
||||||
// empty answers, so the plugin's `default(answers)` for downstream fields
|
// empty answers, so the plugin's `default(answers)` for downstream fields
|
||||||
// computes against synthAnswers defaults — NOT the user's saved values.
|
// computes against synthAnswers defaults — NOT the user's saved values.
|
||||||
// Provider-config-panel then has to issue an initial sync to bring the
|
// Provider-config-panel then has to issue an initial sync to bring the
|
||||||
// schema in line with the saved values.
|
// schema in line with the saved values.
|
||||||
const initialSchema = evaluatePluginConfig(
|
const initialSchema = schemaOverride ??
|
||||||
|
evaluatePluginConfig(
|
||||||
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
|
buildCascadeRawSchema(savedConfig.region as string) as Parameters<
|
||||||
typeof evaluatePluginConfig
|
typeof evaluatePluginConfig
|
||||||
>[0]
|
>[0]
|
||||||
@@ -182,10 +196,12 @@ function getFieldSelectTrigger(label: string) {
|
|||||||
return trigger as HTMLElement
|
return trigger as HTMLElement
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel() {
|
function renderPanel(
|
||||||
|
draftConfigMap: Record<string, ProviderDraftConfigItem | undefined> = {}
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<ProviderConfigPanel
|
<ProviderConfigPanel
|
||||||
draftConfigMap={{}}
|
draftConfigMap={draftConfigMap}
|
||||||
setDraftConfigMap={vi.fn()}
|
setDraftConfigMap={vi.fn()}
|
||||||
onCreateConfigIntent={vi.fn()}
|
onCreateConfigIntent={vi.fn()}
|
||||||
onDeleteConfigIntent={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', () => {
|
describe('SchemaFormFields unknown field type', () => {
|
||||||
it('does not render any input control for an unrecognized type, label only', () => {
|
it('does not render any input control for an unrecognized type, label only', () => {
|
||||||
renderSchema([
|
renderSchema([
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ const defaultWebPreferences = {
|
|||||||
preload: path.join(__dirname, '../preload/index.js'),
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegrationInWorker: false,
|
nodeIntegrationInWorker: false
|
||||||
backgroundThrottling: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWindowParams = (windowURL: string) => {
|
const handleWindowParams = (windowURL: string) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
|
|||||||
import { dialog } from 'electron'
|
import { dialog } from 'electron'
|
||||||
import windowManager from '~/main/apis/app/window/windowManager'
|
import windowManager from '~/main/apis/app/window/windowManager'
|
||||||
import { IWindowList } from '#/types/enum'
|
import { IWindowList } from '#/types/enum'
|
||||||
|
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||||
|
|
||||||
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
|
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
|
||||||
|
|
||||||
@@ -210,7 +211,10 @@ pluginsRouter
|
|||||||
} else if (payload.target === 'uploader') {
|
} else if (payload.target === 'uploader') {
|
||||||
const handler = picgo.helper.uploader.get(payload.uploaderName)
|
const handler = picgo.helper.uploader.get(payload.uploaderName)
|
||||||
if (handler?.config) {
|
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 =
|
type IRefreshConfigSchemaArgs =
|
||||||
| { target: 'plugin', pluginFullName: string, draftValues?: Record<string, unknown> }
|
| { target: 'plugin', pluginFullName: string, draftValues?: Record<string, unknown> }
|
||||||
| { target: 'transformer', 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 {
|
export {
|
||||||
pluginsRouter
|
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 =
|
export type IRefreshConfigSchemaArgs =
|
||||||
| { target: 'plugin', pluginFullName: string, draftValues: Record<string, unknown> }
|
| { target: 'plugin', pluginFullName: string, draftValues: Record<string, unknown> }
|
||||||
| { target: 'transformer', 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 {
|
interface PluginInstallResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface SchemaFormFieldsProps {
|
|||||||
schema: ProviderPluginConfig[]
|
schema: ProviderPluginConfig[]
|
||||||
values: SchemaFormValues
|
values: SchemaFormValues
|
||||||
fieldErrors?: SchemaFieldErrorMap
|
fieldErrors?: SchemaFieldErrorMap
|
||||||
|
allowPasswordReveal?: boolean
|
||||||
onValueChange: (name: string, value: unknown) => void
|
onValueChange: (name: string, value: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,14 @@ interface CheckboxFieldProps {
|
|||||||
onValueChange: (name: string, value: unknown) => void
|
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) {
|
function renderSanitizedTips(markdown: string) {
|
||||||
const parsed = marked.parse(markdown)
|
const parsed = marked.parse(markdown)
|
||||||
const html = typeof parsed === "string" ? parsed : 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({
|
export function SchemaFormFields({
|
||||||
schema,
|
schema,
|
||||||
values,
|
values,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
allowPasswordReveal = true,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
}: SchemaFormFieldsProps) {
|
}: SchemaFormFieldsProps) {
|
||||||
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>(
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{schema.map((field) => {
|
{schema.map((field) => {
|
||||||
@@ -169,7 +213,6 @@ export function SchemaFormFields({
|
|||||||
const optionValueMap = new Map(
|
const optionValueMap = new Map(
|
||||||
choices.map((choice) => [String(choice.value), choice.value] as const)
|
choices.map((choice) => [String(choice.value), choice.value] as const)
|
||||||
)
|
)
|
||||||
const isPasswordVisible = Boolean(visiblePasswords[field.name])
|
|
||||||
const fieldError = fieldErrors[field.name]
|
const fieldError = fieldErrors[field.name]
|
||||||
const isInvalid = Boolean(fieldError)
|
const isInvalid = Boolean(fieldError)
|
||||||
|
|
||||||
@@ -230,34 +273,14 @@ export function SchemaFormFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.type === "password" && (
|
{field.type === "password" && (
|
||||||
<div className="relative">
|
<PasswordField
|
||||||
<Input
|
key={`${field.name}:${allowPasswordReveal ? "reveal" : "locked"}`}
|
||||||
value={String(value ?? "")}
|
field={field}
|
||||||
type={isPasswordVisible ? "text" : "password"}
|
selectedValue={value}
|
||||||
placeholder={field.message || field.name}
|
isInvalid={isInvalid}
|
||||||
className="pr-10"
|
allowPasswordReveal={allowPasswordReveal}
|
||||||
aria-invalid={isInvalid}
|
onValueChange={onValueChange}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.type === "list" && (
|
{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"
|
className="group h-10 cursor-pointer bg-card/80 px-4 backdrop-blur transition-all hover:border-primary/30"
|
||||||
disabled={disabled}
|
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">
|
<span className="text-sm font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||||
{current.providerName}
|
{current.providerName}
|
||||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
<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 { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { pluginsAdapter } from "@/adapters/plugins"
|
||||||
import { appActions, providerStoreActions, useAppStore, useProviderStore } from "@/store"
|
import { appActions, providerStoreActions, useAppStore, useProviderStore } from "@/store"
|
||||||
import { ProviderConfigNameDialog } from "./provider-config-name-dialog"
|
import { ProviderConfigNameDialog } from "./provider-config-name-dialog"
|
||||||
import { ProviderConfigPanel } from "./provider-config-panel"
|
import { ProviderConfigPanel } from "./provider-config-panel"
|
||||||
@@ -35,7 +36,6 @@ export function PicGoProviders() {
|
|||||||
|
|
||||||
const appConfig = useAppStore.use.appConfig()
|
const appConfig = useAppStore.use.appConfig()
|
||||||
const providers = useAppStore.use.providers()
|
const providers = useAppStore.use.providers()
|
||||||
const providerSchemas = useAppStore.use.providerSchemas()
|
|
||||||
const hasHydrated = useAppStore.use.hasHydrated()
|
const hasHydrated = useAppStore.use.hasHydrated()
|
||||||
const isLoadingUploaders = useProviderStore.use.isHydrating()
|
const isLoadingUploaders = useProviderStore.use.isHydrating()
|
||||||
|
|
||||||
@@ -167,14 +167,12 @@ export function PicGoProviders() {
|
|||||||
|
|
||||||
const handleCreateConfigDraft = async (uploaderId: string, configName: string) => {
|
const handleCreateConfigDraft = async (uploaderId: string, configName: string) => {
|
||||||
try {
|
try {
|
||||||
const resolvedSchema =
|
const resolvedSchema = await pluginsAdapter.refreshConfigSchema({
|
||||||
(await providerStoreActions.ensureSchema(uploaderId)).config ??
|
target: "uploader",
|
||||||
providerSchemas[uploaderId]?.config
|
uploaderName: uploaderId,
|
||||||
|
draftValues: {},
|
||||||
if (!resolvedSchema) {
|
schemaOnly: true,
|
||||||
toast.error(t("FAILED"))
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const draftId = createDraftConfigId(uploaderId)
|
const draftId = createDraftConfigId(uploaderId)
|
||||||
|
|||||||
@@ -300,9 +300,11 @@ export function ProviderConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProviderFormFields
|
<ProviderFormFields
|
||||||
|
key={`${selectedUploaderId}:${selectedConfig._id}`}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
values={formValues}
|
values={formValues}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
allowPasswordReveal={isDraftSelected}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -335,4 +337,3 @@ export function ProviderConfigPanel({
|
|||||||
</AppMainCard>
|
</AppMainCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ export function useProviderConfigForm({
|
|||||||
target: "uploader",
|
target: "uploader",
|
||||||
uploaderName: refreshTarget.uploaderName,
|
uploaderName: refreshTarget.uploaderName,
|
||||||
draftValues,
|
draftValues,
|
||||||
|
schemaOnly: isDraftSelected,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[]
|
[isDraftSelected]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Re-hydrate when the persisted config's _updatedAt changes (initial load,
|
// Re-hydrate when the persisted config's _updatedAt changes (initial load,
|
||||||
@@ -113,6 +114,9 @@ export function useProviderConfigForm({
|
|||||||
.then((nextSchema) => {
|
.then((nextSchema) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveSchema(nextSchema)
|
setLiveSchema(nextSchema)
|
||||||
|
if (isDraftSelected) {
|
||||||
|
setValues(buildFormValues(nextSchema, selectedConfig))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|||||||
Vendored
+2
-1
@@ -144,7 +144,8 @@ interface IBrowserWindowOptions {
|
|||||||
nodeIntegration: boolean,
|
nodeIntegration: boolean,
|
||||||
nodeIntegrationInWorker: boolean,
|
nodeIntegrationInWorker: boolean,
|
||||||
contextIsolation: boolean,
|
contextIsolation: boolean,
|
||||||
backgroundThrottling: boolean
|
// 不传即沿用 Electron 默认值(true,窗口不可见时降频);仅在某个窗口确实需要后台全速渲染时才显式关闭。
|
||||||
|
backgroundThrottling?: boolean
|
||||||
webSecurity?: boolean
|
webSecurity?: boolean
|
||||||
},
|
},
|
||||||
vibrancy?: string | any,
|
vibrancy?: string | any,
|
||||||
|
|||||||
Reference in New Issue
Block a user