Compare commits

..
33 Commits
Author SHA1 Message Date
PiEgg 9ba9c3e30c 🎉 Release: v3.0.0 2026-07-01 11:15:32 +08:00
PiEgg 810cfb1963 🐛 Fix: picgo-core link bug 2026-07-01 11:15:08 +08:00
PiEggandGitHub 93bbb29d54 Feature: v3 (#1414) 2026-07-01 10:22:36 +08:00
PiEgg 01130f2a0a 📝 Docs: update readme 2026-06-10 15:56:38 +08:00
PikammmmmandGitHub 7bd84a7216 📝 Docs: fix https// typo in Chinese FAQ #1 (#1410) 2026-05-04 08:28:37 +08:00
PiEggandGitHub d3dcd2362b 🐛 Fix: signed path extract error (#1407) 2026-04-11 19:42:20 +08:00
PiEggandGitHub e1b04b838e 🐛 Fix(gui): migrate renderer electron access to preload bridge (#1405) 2026-04-04 00:13:20 +08:00
PiEggandGitHub 43a21de380 fix: pin pnpm to 10.29.2 for release builds (#1400) 2026-03-06 17:09:42 +08:00
PiEgg ff85edeaaf 📝 Docs: add 2.5.3 docs 2026-03-06 15:30:30 +08:00
PiEgg ba9fd5c1af 🎉 Release: v2.5.3 2026-03-06 14:38:46 +08:00
PiEggandGitHub c7ca0de0c3 🐛 Fix(update): correct latest version lookup with beta channel (#1396) (#1397) 2026-03-03 16:03:29 +08:00
PiEggandGitHub 0e452e1459 🐛 Fix(plugin): refresh plugin config dialog state (#1395)
ISSUE_CLOSED: #1394
2026-03-03 15:11:31 +08:00
PiEggandGitHub f05ab6348a 📦 Chore: add oz agent for issues (#1392) 2026-02-26 22:41:36 +08:00
qrzbingandGitHub 5d0f01675c 📝 Docs(custom): update README (#1390) 2026-02-24 20:04:36 +08:00
PiEgg 87d92d51e0 📝 Docs: update sponsor 2026-02-21 22:07:58 +08:00
PiEgg ac9a00bf25 📝 Docs: update docs 2026-02-10 22:16:26 +08:00
PiEgg 84fe4259eb 🎉 Release: v2.5.2 2026-02-10 22:08:05 +08:00
PiEgg a1f76cac9a 🐛 Fix: s.ee upload error
ISSUES CLOSED: #1385
2026-02-10 22:07:38 +08:00
PiEgg d0defeede9 📝 Docs: update readme for s.ee 2026-02-10 15:47:32 +08:00
PiEgg 5008a92842 📝 Docs: add 2.5.1 docs 2026-02-10 15:26:38 +08:00
PiEgg 905e34aefe 🎉 Release: v2.5.1 2026-02-10 12:21:13 +08:00
PiEgg 592f9855f8 📦 Chore(notification): add notification for windows sign (#1387) 2026-02-10 11:38:46 +08:00
PiEgg e2e05aef49 📦 Chore: update picgo version to support s.ee
ISSUES CLOSED: #1385
2026-02-10 10:30:55 +08:00
PiEggandGitHub 781cf30c55 Feature: windows signature (#1386) 2026-02-10 10:25:53 +08:00
PiEgg 27464dcf29 📦 Chore: update link 2026-02-04 10:54:46 +08:00
PiEgg ef07c15085 🐛 Fix(plugin): plugin search error
ISSUES CLOSED: #1383
2026-01-31 08:13:12 +08:00
PiEgg 8952c98351 🎉 Release: v2.5.0 2026-01-27 16:57:41 +08:00
PiEgg 5cabbbdb90 🐛 Fix: review changes 2026-01-27 16:56:35 +08:00
PiEggandGitHub 43447726ef Feature: add picgo cloud and config sync (#1382)
ISSUES CLOSED: #1381
2026-01-27 16:52:23 +08:00
PiEggandGitHub 678cb713a9 📦 Chore(multi-arch): add multi-arch build support (#1379) 2026-01-20 14:54:55 +08:00
PiEggandGitHub 32f501cadb 🔨 Refactor(config): multi-config handle by picgo-core (#1378) 2026-01-13 20:41:15 +08:00
PiEgg 4fb3f2d488 📝 Docs(faq): update faq 2026-01-12 21:18:36 +08:00
PiEgg 5eb3755fa2 📝 Docs(release): update 2.4.3 docs 2026-01-12 20:57:54 +08:00
416 changed files with 42971 additions and 9201 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ body:
label: 前置阅读 | Pre-reading
description: 我已经自行查找、阅读以下内容(阅读了请打勾) | I have searched and read the following on my own (Please tick after reading)
options:
- label: "[文档/Doc](https://picgo.github.io/PicGo-Doc/)"
- label: "[文档/Doc](https://docs.picgo.app/gui/)"
required: true
- label: "[Issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed)"
required: true
@@ -65,4 +65,4 @@ body:
最后,喜欢 PicGo 的话不妨给它点个 star~
如果可以的话,请我喝杯咖啡?首页有赞助二维码,谢谢你的支持!
Finally, if you like PicGo, give it a star~
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
+2 -2
View File
@@ -22,7 +22,7 @@ body:
label: 前置阅读 | Pre-reading
description: 我已经自行查找、阅读以下内容(阅读了请打勾) | I have searched and read the following on my own (Please tick after reading)
options:
- label: "[文档/Doc](https://picgo.github.io/PicGo-Doc/)"
- label: "[文档/Doc](https://docs.picgo.app/gui/)"
required: true
- label: "[Issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed)"
required: true
@@ -60,4 +60,4 @@ body:
最后,喜欢 PicGo 的话不妨给它点个 star~
如果可以的话,请我喝杯咖啡?首页有赞助二维码,谢谢你的支持!
Finally, if you like PicGo, give it a star~
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
@@ -0,0 +1,71 @@
name: Issue Duplicate Detection
on:
issues:
types:
- opened
- edited
- reopened
permissions:
contents: read
issues: write
jobs:
detect-duplicate-issues:
runs-on: ubuntu-latest
steps:
- name: Run Warp Agent duplicate detector
uses: warpdotdev/warp-agent-action@v1
id: duplicate_detector
env:
GH_TOKEN: ${{ github.token }}
with:
warp_api_key: ${{ secrets.WARP_API_KEY }}
profile: ${{ vars.WARP_AGENT_PROFILE || '' }}
prompt: |
You are triaging duplicate GitHub issues for this repository.
Repository: ${{ github.repository }}
Target issue number: #${{ github.event.issue.number }}
Target issue URL: ${{ github.event.issue.html_url }}
Target issue title: ${{ github.event.issue.title }}
Target issue body:
${{ github.event.issue.body }}
Use the GitHub CLI with GH_TOKEN for all operations.
Workflow requirements:
1. Gather issue context from title/body/error messages/symptoms/components.
- Run:
gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json number,title,body,url,state,labels
2. Search with multiple strategies:
- title keywords
- error message fragments
- symptom words
- component/module names
Example command pattern:
gh issue list --repo ${{ github.repository }} --state all --search "<query>"
3. Inspect every candidate in detail:
gh issue view <candidate_number> --repo ${{ github.repository }} --json number,title,body,url,state
4. Duplicate threshold:
- only mark duplicate when confidence >= 90%
- same root cause + very similar symptoms/errors/components
5. Exclusions:
- never include pull requests
- never include the current issue itself (#${{ github.event.issue.number }})
6. If confidence is insufficient or no duplicates exist, exit without commenting.
7. If duplicates exist, create or update exactly one comment on issue #${{ github.event.issue.number }}:
- first line must be: <!-- issue-duplicate-detector -->
- include markdown bullet list with title + link:
- [Issue title](${{ github.server_url }}/${{ github.repository }}/issues/123)
8. Before posting, check existing comments on the target issue:
- if marker comment exists, update that comment
- otherwise create a new comment
9. Do not comment on any other issue.
Expected comment format:
<!-- issue-duplicate-detector -->
Detected potentially duplicate issues:
- [Duplicate issue title](${{ github.server_url }}/${{ github.repository }}/issues/123)
- [Another duplicate issue](${{ github.server_url }}/${{ github.repository }}/issues/456)
+310 -89
View File
@@ -6,11 +6,11 @@ on:
- v*
workflow_dispatch:
inputs:
test_upload_dist:
description: "Test upload-dist.js script"
required: true
default: false
type: boolean
release_tag:
description: "GitHub release tag to publish to (optional, defaults to current branch like dev)"
required: false
default: ""
type: string
build_os:
description: "Build for specific OS: Windows, macOS, Linux, All"
required: true
@@ -21,97 +21,329 @@ on:
- macOS
- Linux
- All
skip_notarize:
description: "Skip Notarization (true/false)"
test_upload_dist:
description: "Test upload-dist.js script"
required: true
default: false
type: boolean
test_upload_dist_to_dev:
description: "Test upload-dist.js script to dev folder"
required: true
default: false
type: boolean
skip_mac_notarize:
description: "Skip Mac Notarization (true/false)"
required: true
default: false
type: boolean
win_signing_mode:
description: "Windows Signing Mode: release-signing(default) or test-signing"
required: true
default: "release-signing"
type: choice
options:
- release-signing
- test-signing
env:
NODE_VERSION: 22.x
jobs:
# parallel build jobs
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
# ============== macOS Builds ==============
build-macos:
name: Build macOS (${{ matrix.arch }})
if: github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
# if one job fails, do not stop other jobs
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
include:
- runner: macos-15-intel
target: dmg
arch: x64
- runner: macos-latest
target: dmg
arch: arm64
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
version: 10.29.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace on Windows
if: runner.os == 'Windows' && (github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
- name: Clean workspace
run: rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build macOS App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --mac ${{ matrix.target }} --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_NOTARIZE: ${{ inputs.skip_mac_notarize }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-macOS-${{ matrix.arch }}
path: dist/*.*
# ============== Windows Builds ==============
build-windows:
name: Build Windows (${{ matrix.arch }})
if: github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
arch: x64-ia32
build_arch: --x64 --ia32
target: nsis
- runner: windows-11-arm
arch: arm64
build_arch: --arm64
target: nsis
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.29.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace
run: |
if (Test-Path dist) { Remove-Item -Recurse -Force dist }
if (Test-Path dist_electron) { Remove-Item -Recurse -Force dist_electron }
if (Test-Path node_modules) { Remove-Item -Recurse -Force node_modules }
if (Test-Path "$env:LOCALAPPDATA\electron-builder") {
Remove-Item "$env:LOCALAPPDATA\electron-builder" -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path "$env:LOCALAPPDATA\electron") {
Remove-Item "$env:LOCALAPPDATA\electron" -Recurse -Force -ErrorAction SilentlyContinue
}
- name: Clean workspace on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux' && (github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: |
rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Ubuntu Update with sudo
if: runner.os == 'Linux' && (github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
# 1. Build (Unsigned)
- name: Build Windows App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --win ${{matrix.target}} ${{ matrix.build_arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# 2. Upload Unsigned Artifact to SignPath (Intermediate Step)
- name: Upload Unsigned Artifact for Signing
id: upload-unsigned
uses: actions/upload-artifact@v4
with:
name: unsigned-${{ matrix.arch }}
path: dist/*.exe
retention-days: 1
if-no-files-found: error
- name: Notification for Signing Start
shell: bash
env:
NOTIFY_URL: ${{ secrets.SIGN_NOTIFICATION }}
run: curl "$NOTIFY_URL"
# 3. Submit to SignPath and Wait
- name: Sign Artifact with SignPath
uses: signpath/github-action-submit-signing-request@v2
env:
SIGNPATH_SIGNING_POLICY_SLUG: |
${{ (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || inputs.win_signing_mode == 'release-signing')
&& 'release-signing'
|| 'test-signing' }}
ARTIFACT_SLUG: |
${{ (matrix.arch == 'x64-ia32')
&& 'PicGo-Windows'
|| (matrix.arch == 'arm64')
&& 'PicGo-Windows-ARM64'
|| '' }}
with:
api-token: "${{ secrets.SIGNPATH_API_TOKEN }}"
organization-id: "${{ secrets.SIGNPATH_ORGANIZATION_ID }}"
project-slug: "${{ secrets.SIGNPATH_PROJECT_SLUG }}"
signing-policy-slug: "${{ env.SIGNPATH_SIGNING_POLICY_SLUG }}"
github-artifact-id: "${{ steps.upload-unsigned.outputs.artifact-id }}"
artifact-configuration-slug: "${{ env.ARTIFACT_SLUG }}"
wait-for-completion: true
output-artifact-directory: "signed-artifact"
# 4. Replace Unsigned with Signed & Fix Blockmap (Critical for Auto-Update)
- name: Replace Unsigned with Signed & Update latest.yml
shell: powershell
run: |
sudo apt-get update
sudo apt-get install -y libfuse2
- name: Build Windows x64 & ARM64 App
if: runner.os == 'Windows' && (github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:win || true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build macOS x64 & ARM64 App
if: runner.os == 'macOS' && (github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:mac || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# macOS Code Signing
# p12 证书的 Base64 字符串
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
# p12 证书密码
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
# macOS Notarization (公证所需变量)
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_NOTARIZE: ${{ inputs.skip_notarize }}
- name: Build Linux x64 & ARM64 App
if: runner.os == 'Linux' && (github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:linux || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
Write-Host "[Info] Contents of signed-artifact:"
Get-ChildItem -Path "signed-artifact" -Recurse | Format-Table FullName, Length
# If returned zip files, extract them first
$zips = Get-ChildItem -Path "signed-artifact\*.zip" -ErrorAction SilentlyContinue
if ($zips) {
Write-Host "[Info] Found zip file(s), extracting..."
foreach ($zip in $zips) {
Expand-Archive -Path $zip.FullName -DestinationPath "signed-artifact" -Force
Remove-Item -Path $zip.FullName -Force
Write-Host "[Done] Extracted: $($zip.Name)"
}
Write-Host "[Info] Contents after extraction:"
Get-ChildItem -Path "signed-artifact" -Recurse | Format-Table FullName, Length
}
$exeFiles = Get-ChildItem -Path "signed-artifact\*.exe" -ErrorAction SilentlyContinue
if ($exeFiles) {
foreach ($exe in $exeFiles) {
Move-Item -Path $exe.FullName -Destination "dist\" -Force
Write-Host "[Done] Moved signed artifact: $($exe.Name)"
}
} else {
Write-Host "[Error] No .exe files found in signed-artifact!"
Get-ChildItem -Path "signed-artifact" -Recurse
exit 1
}
# Run the Node.js script to update latest.yml
node scripts/update-win-yaml.js
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-${{ runner.os }}
name: PicGo-Windows-${{ matrix.arch }}
path: dist/*.*
- name: Upload to release.picgo.app
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.test_upload_dist
# ============== Linux Builds ==============
build-linux:
name: Build Linux (${{ matrix.arch }})
if: github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: x64
target: AppImage deb snap
- runner: ubuntu-24.04-arm
arch: arm64
target: AppImage deb
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.29.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace
run: rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Linux dependencies
run: |
pnpm run upload-dist
sudo apt-get update
sudo apt-get install -y libfuse2
- name: Build Linux App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --linux ${{matrix.target}} --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-Linux-${{ matrix.arch }}
path: dist/*.*
# ============== Release ==============
release:
name: Merge & Release
needs: [build-macos, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.29.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: PicGo-*
- name: List artifacts
run: ls -laR artifacts/
- name: Merge artifacts and yml files
run: node scripts/merge-artifacts.js
- name: List dist
run: ls -la dist/
- name: Upload 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"
if [[ "${{ github.event.inputs.test_upload_dist_to_dev }}" == "true" ]]; then
ARGS="$ARGS --dev"
echo "🚧 Test Upload Mode: ON"
fi
node scripts/upload-dist.js $ARGS
env:
PICGO_ENV_S3_SECRET_ID: ${{ secrets.PICGO_ENV_S3_SECRET_ID }}
PICGO_ENV_S3_SECRET_KEY: ${{ secrets.PICGO_ENV_S3_SECRET_KEY }}
@@ -119,37 +351,27 @@ jobs:
PICGO_ENV_S3_LEGACY_ACCOUNT_ID: ${{ secrets.PICGO_ENV_S3_LEGACY_ACCOUNT_ID }}
PICGO_ENV_S3_LEGACY_SECRET_ID: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_ID }}
PICGO_ENV_S3_LEGACY_SECRET_KEY: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_KEY }}
release:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish GitHub Dev Release
- name: Publish GitHub Workflow Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
continue-on-error: true
with:
token: ${{ secrets.GH_TOKEN }}
tag_name: dev
tag_name: ${{ github.event.inputs.release_tag || github.ref_name }}
draft: true
prerelease: false
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.AppImage
dist/*.deb
dist/*.snap
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
@@ -160,13 +382,12 @@ jobs:
draft: true
prerelease: false
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.AppImage
dist/*.deb
dist/*.snap
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
+3
View File
@@ -24,8 +24,11 @@ scripts/*.yml
#Electron-builder output
/dist_electron
.serena/
.claude/
dist/*
test.js
specs/
.cache/
openspec/
bug*
Trace-*.json
+1
View File
@@ -0,0 +1 @@
!openspec/
+43 -5
View File
@@ -12,12 +12,49 @@ PicGo is an Electron + Vue 3 desktop client. Source lives in `src/`: `src/main`
- `pnpm lint` / `pnpm lint:fix` — run or auto-fix ESLint (Standard, TypeScript, Vue rules).
- `pnpm lint:dpdm` — fail fast on circular dependencies in `src/`.
- `pnpm check` — run `tsc` + `lint` (run once before finishing a task).
- `pnpm gen-i18n` — regenerate typed locales after touching `public/i18n/*.yml`.
- Before completing a task, always run `pnpm check` and resolve any issues it reports.
- i18n type files are auto-generated by the Vite `i18nTypesPlugin` when `public/i18n/*.yml` changes. Do not add or rely on a manual `gen-i18n` step.
## Coding Style & Naming Conventions
Follow ESLint Standard defaults: two-space indentation, single quotes, trailing commas where allowed, and no stray semicolons. Author new modules in TypeScript. Keep renderer files browser-safe; route Node APIs through IPC helpers such as `src/main/events/picgoCoreIPC.ts`. Name Vue components in PascalCase (`UploadPanel.vue`) and use camelCase for utilities. Centralize IPC event names inside `src/universal/events/constants.ts`, and store enums/types under `src/universal/types/` so they stay reusable. Static assets are served from `public/` and resolved via `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`); avoid using `__static` directly.
Static assets are served from `public/`. In the main process use `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`). In the renderer, place assets under `public/` and resolve them via `import.meta.env.BASE_URL + filename` (helper: `src/renderer/utils/static.ts`); do not rely on `__static` in renderer code.
- Do not use `as any` under any circumstances; keep typings explicit and safe.
- Avoid `as any` in tests as well; build concrete typed stubs (e.g., `IpcMainInvokeEvent`) instead.
- Do not prefix method calls with `void` (e.g. use `store?.refreshPicBeds()` rather than `void store?.refreshPicBeds()`).
- Do not write `void someMethod()` or `void object.method()` anywhere in the codebase. If you need fire-and-forget behavior, use an `async` callback with `await`, or handle errors explicitly with `try/catch` and logging instead of swallowing them.
- Because the renderer uses React Compiler, do not add `useMemo` or `useCallback` by default. Prefer plain values and inline functions unless a specific API requires stable identity or there is a proven performance issue.
- For Zustand store state/actions, prefer reading them directly in the consuming component with `useAppStore` / `useStore` instead of passing them down through unnecessary prop layers. If a child can access the needed store value or action itself, do not thread it through parent props.
- If a child component only needs a store action (for example `providerStoreActions.toggleExpanded`, `settingsStoreActions.setSearchValue`, or `galleryStoreActions.setViewMode`), do not pass that store action through props. Import and use the action directly inside the child component.
- Renderer Zustand stores must follow the project store architecture:
- `src/renderer/store/app-store.ts` is for true global renderer state only.
- Feature/page-local UI state must live under `src/renderer/store/<feature>/` (for example `src/renderer/store/gallery/store.ts`, `src/renderer/store/gallery/actions.ts`), not inside `app-store.ts`.
- Do not add an extra nested `store/` directory like `src/renderer/store/gallery/store/store.ts`.
- Zustand state and actions must be separated:
- Do not define state-mutating actions inside `create()`.
- Keep store files focused on state shape and initial state.
- Put global actions in `src/renderer/store/app-actions.ts`.
- Put feature actions in `src/renderer/store/<feature>/actions.ts`.
- Actions must update state via `useXxxStore.setState(...)`.
- Follow strict IPC boundaries for Zustand actions:
- Pure IPC/service calls without Zustand state changes should call the adapter/service directly from the component or helper, not through a Zustand action.
- Flows that combine IPC/service work with Zustand state updates must live in actions files.
- Keep server state and client state separated:
- TanStack Query should own server state: remote API data, loading/error/stale status, refetching, cache, and request dedupe.
- Zustand should own client/UI state: selected source, selected ids, filters, view mode, panel open state, and other local user intent.
- Connect the two with ids, query params, and local UI state (for example `albumSource`, `typeFilter`, or `searchValue`), but do not mirror query response data back into Zustand.
- If server state invalidates a local UI choice (for example a user becomes non-paid while `albumSource` is cloud), use a small effect/action to correct the local UI state instead of storing the whole server response in Zustand.
- When updating nested Zustand state (especially config-like objects), use `zustand/middleware/immer`; do not reintroduce deep `...state` spread chains for nested updates.
- Components must consume Zustand state through auto-generated selectors (for example `useAppStore.use.appConfig()`), not by destructuring the whole store or writing ad-hoc hook selectors in components.
- Renderer-side shared constants (for example responsive breakpoints, UI timing values, fixed dimensions, thresholds, and repeated literal values used across components) should be centralized in `src/renderer/utils/consts.ts` instead of being hardcoded inline in components. When a new renderer constant may be reused or affects shared behavior, add it there first.
- Renderer-side date/time formatting should use `dayjs` and shared format constants from `src/renderer/utils/consts.ts` (for example `DEFAULT_DATE_TIME_FORMAT`) instead of `Intl.DateTimeFormat` or ad-hoc inline format strings.
- Enum-like object constants declared with `as const` (for example status maps, option maps, and value registries) must use PascalCase names, not camelCase. Prefer names like `PicGoCloudRequestStatusValues`, `SettingsAppearanceValues`, or `AppPlatformValues`.
- If a renderer → main request mutates persisted config/state without using `saveConfig`, call `notifyAppConfigUpdated()` in main to inform renderers.
- Prefer enums over union types for discrete value sets (e.g., encryption methods). Avoid introducing new string literal union types.
- Renderer page/component styles should prefer Tailwind utility classes; avoid adding new Vue `<style>` blocks unless there's no reasonable Tailwind equivalent.
- New renderer ↔ main request/response APIs should be implemented via RPC routes (see `src/main/events/rpc/routes/system.ts`) with `RPCRouter` + `IRPCActionType` rather than adding ad-hoc IPC modules (e.g. `picgoCloudIPC`).
- For request/response semantics in renderer, prefer `invokeRPC` (backed by `ipcMain.handle(RPC_ACTIONS, ...)` in `src/main/events/rpc/index.ts`).
- **AlertDialog async confirm buttons**: Do not use `AlertDialogAction` for confirm buttons that perform async operations (API calls, etc.), because `AlertDialogAction` auto-closes the dialog on click regardless of `event.preventDefault()`. Use a plain `<Button>` instead, manage `open` state manually, and close the dialog only after the async operation completes (success or error). See `src/renderer/components/main/gallery/gallery-delete-dialog.tsx` for reference.
- **Nullish coalescing for optional checks**: Prefer `(value?.field ?? fallback) > 0` over verbose `value !== null && value !== undefined && typeof value.field === 'number' && value.field > 0` chains. Use TypeScript's optional chaining (`?.`) and nullish coalescing (`??`) to keep boolean checks concise.
## Testing Guidelines
Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma picks them up via `require.context`. Run them with `npx karma start test/unit/karma.conf.js --single-run` and ensure new renderer folders are covered. Spectron e2e cases live in `test/e2e/specs`; build first (`pnpm build`), then run `npx mocha test/e2e/index.js` so Spectron can launch `dist/electron/main.js`. Document any test data, IPC stubs, or fixtures you add to keep suites reproducible.
@@ -26,7 +63,8 @@ Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma
Commits follow the PicGo conventional preset enforced by Husky (`pnpm lint:dpdm` + Commitlint). Stage your changes and run `pnpm cz` to craft messages that pass CI. Pull requests should explain the change, link related issues, and attach UI screenshots or recordings. Note how you validated the work (dev server, build, Karma, Spectron) and call out migration or configuration steps reviewers must perform.
## Internationalization Tips
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. After editing `public/i18n/*.yml`, run `pnpm gen-i18n` to regenerate TS typings and keep them in sync.
## Serena MCP & Context7 Tools
When starting work or if you hit issues, try checking MCP for Serena or Context7 tooling. If available, use those tools to navigate, edit, or fetch docs efficiently.
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. Typed i18n declarations are generated automatically from `public/i18n/en.yml` into `src/universal/types/i18n.d.ts` and `src/renderer/i18n/i18next.d.ts`.
- Any user-facing copy (UI text, error messages, warnings, prompts, tips, notifications, etc.) MUST use i18n keys. Do not hardcode strings in code.
- Renderer: use `$T('KEY')` from `src/renderer/i18n/index.ts`.
- Main process: use `T('KEY')` from `src/main/i18n/index.ts`.
- Add new keys to all locales under `public/i18n/` (at least `en.yml`, `zh-CN.yml`, `zh-TW.yml`). The Vite i18n types plugin will regenerate the shared declarations automatically.
+106
View File
@@ -1,3 +1,109 @@
# :tada: 3.0.0 (2026-07-01)
### :sparkles: Features
* v3 ([#1414](https://github.com/Molunerfinn/PicGo/issues/1414)) ([93bbb29](https://github.com/Molunerfinn/PicGo/commit/93bbb29))
### :bug: Bug Fixes
* **gui:** migrate renderer electron access to preload bridge ([#1405](https://github.com/Molunerfinn/PicGo/issues/1405)) ([e1b04b8](https://github.com/Molunerfinn/PicGo/commit/e1b04b8))
* picgo-core link bug ([810cfb1](https://github.com/Molunerfinn/PicGo/commit/810cfb1))
* signed path extract error ([#1407](https://github.com/Molunerfinn/PicGo/issues/1407)) ([d3dcd23](https://github.com/Molunerfinn/PicGo/commit/d3dcd23))
### :pencil: Documentation
* add 2.5.3 docs ([ff85ede](https://github.com/Molunerfinn/PicGo/commit/ff85ede))
* fix `https//` typo in Chinese FAQ [#1](https://github.com/Molunerfinn/PicGo/issues/1) ([#1410](https://github.com/Molunerfinn/PicGo/issues/1410)) ([7bd84a7](https://github.com/Molunerfinn/PicGo/commit/7bd84a7))
* update readme ([01130f2](https://github.com/Molunerfinn/PicGo/commit/01130f2))
## :tada: 2.5.3 (2026-03-06)
### :bug: Bug Fixes
* **plugin:** refresh plugin config dialog state ([#1395](https://github.com/Molunerfinn/PicGo/issues/1395)) ([0e452e1](https://github.com/Molunerfinn/PicGo/commit/0e452e1)), closes [#1394](https://github.com/Molunerfinn/PicGo/issues/1394)
* **update:** correct latest version lookup with beta channel ([#1396](https://github.com/Molunerfinn/PicGo/issues/1396)) ([#1397](https://github.com/Molunerfinn/PicGo/issues/1397)) ([c7ca0de](https://github.com/Molunerfinn/PicGo/commit/c7ca0de))
### :package: Chore
* add oz agent for issues ([#1392](https://github.com/Molunerfinn/PicGo/issues/1392)) ([f05ab63](https://github.com/Molunerfinn/PicGo/commit/f05ab63))
### :pencil: Documentation
* **custom:** update README ([#1390](https://github.com/Molunerfinn/PicGo/issues/1390)) ([5d0f016](https://github.com/Molunerfinn/PicGo/commit/5d0f016))
* update docs ([ac9a00b](https://github.com/Molunerfinn/PicGo/commit/ac9a00b))
* update sponsor ([87d92d5](https://github.com/Molunerfinn/PicGo/commit/87d92d5))
## :tada: 2.5.2 (2026-02-10)
### :bug: Bug Fixes
* s.ee upload error ([a1f76ca](https://github.com/Molunerfinn/PicGo/commit/a1f76ca)), closes [#1385](https://github.com/Molunerfinn/PicGo/issues/1385)
### :pencil: Documentation
* add 2.5.1 docs ([5008a92](https://github.com/Molunerfinn/PicGo/commit/5008a92))
* update readme for s.ee ([d0defee](https://github.com/Molunerfinn/PicGo/commit/d0defee))
## :tada: 2.5.1 (2026-02-10)
### :sparkles: Features
* windows signature ([#1386](https://github.com/Molunerfinn/PicGo/issues/1386)) ([781cf30](https://github.com/Molunerfinn/PicGo/commit/781cf30))
### :bug: Bug Fixes
* **plugin:** plugin search error ([ef07c15](https://github.com/Molunerfinn/PicGo/commit/ef07c15)), closes [#1383](https://github.com/Molunerfinn/PicGo/issues/1383)
### :package: Chore
* **notification:** add notification for windows sign ([#1387](https://github.com/Molunerfinn/PicGo/issues/1387)) ([592f985](https://github.com/Molunerfinn/PicGo/commit/592f985))
* update link ([27464dc](https://github.com/Molunerfinn/PicGo/commit/27464dc))
* update picgo version to support s.ee ([e2e05ae](https://github.com/Molunerfinn/PicGo/commit/e2e05ae)), closes [#1385](https://github.com/Molunerfinn/PicGo/issues/1385)
# :tada: 2.5.0 (2026-01-27)
### :sparkles: Features
* add picgo cloud and config sync ([#1382](https://github.com/Molunerfinn/PicGo/issues/1382)) ([4344772](https://github.com/Molunerfinn/PicGo/commit/4344772)), closes [#1381](https://github.com/Molunerfinn/PicGo/issues/1381)
### :bug: Bug Fixes
* review changes ([5cabbbd](https://github.com/Molunerfinn/PicGo/commit/5cabbbd))
### :package: Chore
* **multi-arch:** add multi-arch build support ([#1379](https://github.com/Molunerfinn/PicGo/issues/1379)) ([678cb71](https://github.com/Molunerfinn/PicGo/commit/678cb71))
### :pencil: Documentation
* **faq:** update faq ([4fb3f2d](https://github.com/Molunerfinn/PicGo/commit/4fb3f2d))
* **release:** update 2.4.3 docs ([5eb3755](https://github.com/Molunerfinn/PicGo/commit/5eb3755))
## :tada: 2.4.3 (2026-01-12)
+25 -101
View File
@@ -1,8 +1,8 @@
## Frequently Asked Questions / 常见问题
> While using PicGo you may run into various issues. Many of them have already been asked and resolved, so please check the [documentation](https://picgo.github.io/PicGo-Doc/guide/getting-started.html#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B), this FAQ, and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed) first — you will likely find the answer there.
> While using PicGo you may run into various issues. Many of them have already been asked and resolved, so please check the [documentation](https://docs.picgo.app/gui/guide/getting-started), this FAQ, and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed) first — you will likely find the answer there.
>
> 在使用 PicGo 期间你会遇到很多问题,不过很多问题其实之前就有人提问过,也被解决,所以你可以先看看 [使用文档](https://picgo.github.io/PicGo-Doc/guide/getting-started.html#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B),这份 FAQ,以及那些被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed),应该能找到答案。
> 在使用 PicGo 期间你会遇到很多问题,不过很多问题其实之前就有人提问过,也被解决,所以你可以先看看 [使用文档](https://docs.picgo.app/gui/guide/getting-started),这份 FAQ,以及那些被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed),应该能找到答案。
## 1. Qiniu image host: upload succeeds but images dont show in Album, or the URL has no `http://` prefix / 七牛图床上传图片成功后,相册里无法显示或图片无`http://`前缀
@@ -10,7 +10,7 @@ This is usually because the `Set URL` (access URL) in your Qiniu image host conf
Reference: [issue#79](https://github.com/Molunerfinn/PicGo/issues/79)
通常是你的七牛图床配置里的`设定访问网址`没有加上`http://`或者`https//`头。
通常是你的七牛图床配置里的`设定访问网址`没有加上`http://`或者`https://`头。
参考:[issue#79](https://github.com/Molunerfinn/PicGo/issues/79)
@@ -148,106 +148,10 @@ If you run into upload issues with the Gitee image host, PicGo cannot help becau
## 12. On macOS, PicGo shows “App is damaged”, or it doesnt respond after installation / macOS系统安装完PicGo显示「文件已损坏」或者安装完打开没有反应
Because PicGo is not signed, it may be blocked by macOS Gatekeeper.
Please try the version >= v2.4.2, which has fixed this issue.
1. If you see “App is damaged” when opening after installation, do the following:
请尝试使用 v2.4.2 及以上版本,已经修复该问题。
Trust the developer (password required):
```
sudo spctl --master-disable
```
Then remove quarantine attributes from PicGo:
```
xattr -cr /Applications/PicGo.app
```
If you see the following message:
```sh
option -r not recognized
usage: xattr [-slz] file [file ...]
xattr -p [-slz] attr_name file [file ...]
xattr -w [-sz] attr_name attr_value file [file ...]
xattr -d [-s] attr_name file [file ...]
xattr -c [-s] file [file ...]
The first form lists the names of all xattrs on the given file(s).
The second form (-p) prints the value of the xattr attr_name.
The third form (-w) sets the value of the xattr attr_name to attr_value.
The fourth form (-d) deletes the xattr attr_name.
The fifth form (-c) deletes (clears) all xattrs.
options:
-h: print this help
-s: act on symbolic links themselves rather than their targets
-l: print long format (attr_name: attr_value)
-z: compress or decompress (if compressed) attribute value in zip format
```
Run:
```
sudo xattr -d com.apple.quarantine /Applications/PicGo.app/
```
2. If PicGo doesnt respond after installation, troubleshoot in this order:
1. PicGo wont automatically pop up a main window on macOS — its designed as a menu bar app. If you can see the PicGo icon in the menu bar, the installation succeeded; click it to open the menu bar window. See FAQ #7.
2. If youre on an Apple Silicon (M1) Mac and previously had the x64 build installed, then switched to the arm64 build and it doesnt respond, reboot your Mac.
因为 PicGo 没有签名,所以会被 macOS 的安全检查所拦下。
1. 安装后打开遇到「文件已损坏」的情况,请按如下方式操作:
信任开发者,会要求输入密码:
```
sudo spctl --master-disable
```
然后放行 PicGo :
```
xattr -cr /Applications/PicGo.app
```
然后就能正常打开。
如果提示以下内容
```sh
option -r not recognized
usage: xattr [-slz] file [file ...]
xattr -p [-slz] attr_name file [file ...]
xattr -w [-sz] attr_name attr_value file [file ...]
xattr -d [-s] attr_name file [file ...]
xattr -c [-s] file [file ...]
The first form lists the names of all xattrs on the given file(s).
The second form (-p) prints the value of the xattr attr_name.
The third form (-w) sets the value of the xattr attr_name to attr_value.
The fourth form (-d) deletes the xattr attr_name.
The fifth form (-c) deletes (clears) all xattrs.
options:
-h: print this help
-s: act on symbolic links themselves rather than their targets
-l: print long format (attr_name: attr_value)
-z: compress or decompress (if compressed) attribute value in zip format
```
执行命令
```
sudo xattr -d com.apple.quarantine /Applications/PicGo.app/
```
2. 如果安装打开后没有反应,请按下方顺序排查:
1. macOS安装好之后,PicGo 是不会弹出主窗口的,因为 PicGo 在 macOS 系统里设计是个顶部栏应用。注意看你顶部栏的图标,如果有 PicGo 的图标,说明安装成功了,点击图标即可打开顶部栏窗口。参考上述第七点。
2. 如果你是 M1 的系统,此前装过 PicGo 的 x64 版本,但是后来更新了 arm64 的版本发现打开后没反应,请重启电脑即可。
## 13. Are third-party plugins claiming to be “PicGo Official image host” trustworthy? / 所谓「PicGo 官方图床」的第三方插件是否可信
@@ -258,3 +162,23 @@ An official PicGo image host (if any) would be built into PicGo out of the box
不可信。所有打着「PicGo 官方图床」旗号的第三方插件(包括不限于 www.picgo.net 等)都不是 PicGo 官方提供的图床或服务,请勿轻信。
PicGo 不会以“第三方插件”的形式要求你另外下载安装所谓的 PicGo 官方图床。如果 PicGo 真的做了官方图床,一定是开箱即用的内置在本体里的。如果你需要使用第三方图床,请优先参考 PicGo 官方维护的插件集合与社区仓库,并自行甄别来源与安全性。
## 14. SM.MS migrated to S.EE: how should I update my config? / SM.MS 迁移到 S.EE 后,配置应该怎么改?
SM.MS uploader has migrated to **S.EE** and changed from the original free plan to a paid service.
To continue uploading normally:
1. Get your API token from [https://s.ee/user/dashboard/](https://s.ee/user/dashboard/).
2. Check your `picBed.smms.backupDomain`:
- if it is an old domain such as `sm.ms` or `smms.app`, remove this field, or
- change it to `s.ee`.
SM.MS 上传器已迁移到 **S.EE**,并且服务已从原本免费改为收费。
如需继续正常上传:
1. 到 [https://s.ee/user/dashboard/](https://s.ee/user/dashboard/) 获取 API Token。
2. 检查你的 `picBed.smms.backupDomain`
- 如果是旧域名(如 `sm.ms``smms.app`),请删除该字段,或
- 改为 `s.ee`
+33 -13
View File
@@ -10,6 +10,22 @@
</div>
<div align="center" markdown="1">
<sup>Sponsored by:</sup>
<br>
<a href="https://console.neon.tech/app/?promo=PicGo">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
</picture>
</a>
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
</div>
---
[中文](./README_zh-CN.md) | **English**
@@ -35,6 +51,9 @@
<a href="https://github.com/PicGo/bump-version">
<img src="https://img.shields.io/badge/picgo-convention-blue.svg?style=flat-square" alt="">
</a>
<a href="https://atomgit.com/Molunerfinn/PicGo">
<img src="https://atomgit.com/Molunerfinn/PicGo/star/badge.svg" alt="">
</a>
</p>
</div>
@@ -49,10 +68,10 @@ Whether youre writing a blog post, taking notes, or authoring developer docs,
PicGo supports mainstream Image hosts out of the box, and can be extended indefinitely through its plugin system:
- **China cloud vendors**: Qiniu, Tencent Cloud COS, UPYUN, Alibaba Cloud OSS
- **International / open platforms**: GitHub, SM.MS, Imgur
- **International / open platforms**: GitHub, SM.MS(S.EE), Imgur
- **More options via plugins**: AWS S3, Cloudflare R2, MinIO, and more
> **Note**: PicGo itself will no longer add new third-party Image hosts by default. You can build Image host plugins yourself—see [PicGo-Core](https://picgo.github.io/PicGo-Core-Doc/).
> **Note**: PicGo itself will no longer add new third-party Image hosts by default. You can build Image host plugins yourself—see [PicGo-Core](https://docs.picgo.app/core/).
## ✨ Key Features
@@ -66,7 +85,7 @@ PicGo is built around a fast, low-friction image upload experience:
### 🚀 Fast uploads
- **Multiple ways to upload**: drag & drop, paste from clipboard, hotkeys, and even right-click context menu upload on macOS/Windows.
- **Global hotkey**: press `Command+Shift+P` (macOS) / `Ctrl+Shift+P` (Windows/Linux) to open the upload window without leaving your current app. The global key can be customized.
- **Global hotkey**: press `Command+Shift+U` (macOS) / `Ctrl+Shift+U` (Windows/Linux) to open the upload window without leaving your current app. The global key can be customized.
### 🧩 Powerful plugin ecosystem
- **Highly extensible**: plugins already exist for AWS S3, Cloudflare R2, MinIO, and many other Image hosts.
@@ -76,22 +95,23 @@ PicGo is built around a fast, low-friction image upload experience:
### 🛠 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.
- **Great documentation**: detailed docs help you get started quickly. For plugin development, see the [PicGo-Core docs](https://picgo.github.io/PicGo-Core-Doc/).
- **Great documentation**: detailed docs help you get started quickly. For plugin development, see the [PicGo-Core docs](https://docs.picgo.app/core/).
> Theres more to discover—development progress is tracked in [Projects](https://github.com/Molunerfinn/PicGo/projects).
If youre new to PicGo, start with the [User Guide](https://picgo.github.io/PicGo-Doc/guide/getting-started.html). If you run into issues, check the [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed).
If youre new to PicGo, start with the [User Guide](https://docs.picgo.app/gui/guide/getting-started). If you run into issues, check the [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed).
## Download & Install
| Source | Link / Installation | Platform | Notes |
| --------------------------------------------------------- | ----------------------------------------------------------- | ---------- | --------------------------------------- |
| GitHub Releases | https://github.com/Molunerfinn/PicGo/releases | All | Downloads may be slow in mainland China |
| [Shandong University mirror](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | Thanks to the mirror for hosting |
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | Thanks to @huangnauh and @Gladtbam |
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | Thanks to @iYato |
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | Thanks to @womeimingzi11 |
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch Linux | Thanks to @houbaron |
| Source | Link / Installation | Platform | Notes |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------- | --------------------------------------- |
| GitHub Releases | https://github.com/Molunerfinn/PicGo/releases | All | Downloads may be slow in mainland China |
| [Shandong University mirror](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | Thanks to the mirror for hosting |
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | Thanks to @huangnauh and @Gladtbam |
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | Thanks to @iYato |
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | Thanks to @womeimingzi11 |
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch Linux | Thanks to @houbaron |
| [Nix](https://search.nixos.org/packages?channel=unstable&query=picgo&show=picgo) | `nix-shell -p picgo` | Nix/NixOS | Thanks to @qrzbing |
## Screenshots
+32 -14
View File
@@ -10,6 +10,21 @@
</div>
<div align="center" markdown="1">
<sup>Sponsored by:</sup>
<br>
<a href="https://console.neon.tech/app/?promo=PicGo">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://neon.com/brand/neon-logo-dark-color.svg">
<source media="(prefers-color-scheme: light)" srcset="https://neon.com/brand/neon-logo-light-color.svg">
<img alt="Neon sponsorship" width="400" src="https://neon.com/brand/neon-logo-dark-color.svg">
</picture>
</a>
### [Fast Postgres Databases for Teams and Agents](https://console.neon.tech/app/?promo=PicGo)
</div>
---
**中文** | [English](./README.md)
@@ -36,6 +51,9 @@
<a href="https://github.com/PicGo/bump-version">
<img src="https://img.shields.io/badge/picgo-convention-blue.svg?style=flat-square" alt="">
</a>
<a href="https://atomgit.com/Molunerfinn/PicGo">
<img src="https://atomgit.com/Molunerfinn/PicGo/star/badge.svg" alt="">
</a>
</p>
</div>
@@ -50,10 +68,10 @@
PicGo 原生支持主流图床平台,并可通过插件系统无限扩展:
- **国内云厂商**:七牛云、腾讯云 COS、又拍云、阿里云 OSS
- **国际/开源平台**GitHub、SM.MS、Imgur
- **国际/开源平台**GitHub、SM.MS(S.EE)、Imgur
- **更多支持**:通过插件支持 AWS S3、Cloudflare R2、MinIO 等第三方图床
> **注意**:PicGo 本体不再增加默认的第三方图床支持。你可以自行开发第三方图床插件。详见 [PicGo-Core](https://picgo.github.io/PicGo-Core-Doc/)。
> **注意**:PicGo 本体不再增加默认的第三方图床支持。你可以自行开发第三方图床插件。详见 [PicGo-Core](https://docs.picgo.app/core/)。
## ✨ 特色功能
@@ -67,7 +85,7 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
### 🚀 极速上传体验
- **多维上传方式**:支持拖拽图片、剪贴板粘贴、快捷键上传,甚至在 macOS/Windows 上支持右键菜单直接上传。
- **全局快捷键**:默认 `Command+Shift+P` (macOS) / `Ctrl+Shift+P` (Windows/Linux) 即可唤起上传,无需离开当前窗口。 快捷键可自定义。
- **全局快捷键**:默认 `Command+Shift+U` (macOS) / `Ctrl+Shift+U` (Windows/Linux) 即可唤起上传,无需离开当前窗口。 快捷键可自定义。
### 🧩 强大的插件生态
- **高度可扩展**:PicGo 拥有丰富的插件系统,已有插件支持 AWS S3、Cloudflare R2、MinIO 等第三方图床。
@@ -77,23 +95,23 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
### 🛠 开发者友好
- **HTTP API**:支持通过 HTTP 请求调用 PicGo 上传 (v2.2.0+),方便与其他工具集成。
- **开源透明**:代码完全开源,安全可靠。
- **丰富的文档**:详尽的开发文档助你快速上手。插件开发请参考 [PicGo-Core 文档](https://picgo.github.io/PicGo-Core-Doc/)。
- **丰富的文档**:详尽的开发文档助你快速上手。插件开发请参考 [PicGo-Core 文档](https://docs.picgo.app/core/)。
> 更多功能等你自己去发现,开发进度可以查看 [Projects](https://github.com/Molunerfinn/PicGo/projects)。
**如果第一次使用,请参考应用 [使用文档](https://picgo.github.io/PicGo-Doc/guide/getting-started.html)。遇到问题了还可以看看 [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) 以及被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed)。**
**如果第一次使用,请参考应用 [使用文档](https://docs.picgo.app/gui/guide/getting-started)。遇到问题了还可以看看 [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) 以及被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed)。**
## 下载安装
| 下载源 | 地址/安装方式 | 平台 | 备注 |
| --------------------------------------------- | ----------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
| GitHub Release | https://github.com/Molunerfinn/PicGo/releases | All | 国内下载速度可能会慢 |
| [山东大学镜像站](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | 感谢 [山东大学镜像站](https://mirrors.sdu.edu.cn/) 提供的镜像支持 |
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | 感谢 @huangnauh@Gladtbam 的贡献 |
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | 感谢 @iYato 的贡献 |
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | 感谢 @womeimingzi11 的贡献 |
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch-Linux | 感谢 @houbaron 的贡献 |
| 下载源 | 地址/安装方式 | 平台 | 备注 |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
| GitHub Release | https://github.com/Molunerfinn/PicGo/releases | All | 国内下载速度可能会慢 |
| [山东大学镜像站](https://mirrors.sdu.edu.cn/) | https://mirrors.sdu.edu.cn/github-release/Molunerfinn_PicGo | All | 感谢 [山东大学镜像站](https://mirrors.sdu.edu.cn/) 提供的镜像支持 |
| [Scoop](https://scoop.sh/) | `scoop bucket add extras` & `scoop install picgo` | Windows | 感谢 @huangnauh@Gladtbam 的贡献 |
| [Chocolatey](https://chocolatey.org/) | `choco install picgo` | Windows | 感谢 @iYato 的贡献 |
| [Homebrew](https://brew.sh/) | `brew install picgo --cask` | macOS | 感谢 @womeimingzi11 的贡献 |
| [AUR](https://aur.archlinux.org/packages/yay) | `yay -S picgo-appimage` | Arch-Linux | 感谢 @houbaron 的贡献 |
| [Nix](https://search.nixos.org/packages?channel=unstable&query=picgo&show=picgo) | `nix-shell -p picgo` | Nix/NixOS | 感谢 @qrzbing 的贡献 |
## 应用截图
+17
View File
@@ -0,0 +1,17 @@
# PicGo 2.4.3 Changelog
## Features
- Add: Batch URL upload support (#1376). See #1302 for details
- Add: [Global URL rewrite](https://docs.picgo.app/core/guide/config.html#settings) support (#1377). See #1281 for details
- Refactor: Gallery image URL can not only rewrite the host but also the whole link. See #1255 for details
----------
## Features
- 新增:支持批量 URL 上传(#1376),参考 #1302
- 新增:支持[全局 URL 重写](https://docs.picgo.app/zh/core/guide/config.html#settings)#1377),参考 #1281
- 重构:相册页的图片 URL 可以批量修改,不仅仅只是修改 HOST,参考 #1255
## Bug Fixes
- 修复:相册页图床列表显示状态与相册不同步的问题(#1373),参考 #1372
- 修复:一些 UI 问题
+21
View File
@@ -0,0 +1,21 @@
# PicGo 2.5.1 Changelog
## Features
- Update: Bump `picgo` dependency to `^2.0.1` to support legacy `sm.ms` migrate to `s.ee` (#1385)
## Bug Fixes
- Fix: Plugin search no longer throws when an npm package description is empty (#1383)
## Other
- Update: Refresh the documentation link in the GitHub bug report template
----------
## Features
- 更新:将 `picgo` 依赖升级到 `^2.0.1` 以支持 `s.ee`。参考 #1385
## Bug Fixes
- 修复:插件搜索在 npm 包描述为空时会报错的问题。参考 #1383
## Other
- 更新:更新 GitHub 问题模板中的文档链接
+9
View File
@@ -0,0 +1,9 @@
# PicGo 2.5.2 Changelog
## Bug Fixes
- Fix: Resolve `s.ee` compatibility issues that could report upload failure even when the upload actually succeeded (#1385)
----------
## Bug Fixes
- 修复:解决 `s.ee` 兼容性问题,上传实际成功时仍可能提示上传失败(#1385
+17
View File
@@ -0,0 +1,17 @@
# PicGo 2.5.3 Changelog
## Bug Fixes
- Fix: Plugin configuration dialogs now refresh correctly when switching between plugins, avoiding stale form data from the previous plugin (#1394)
- Fix: Latest version checking now compares stable and beta releases correctly when beta updates are enabled, avoiding incorrect update prompts (#1396)
## Other
- Update: README now includes a Nix/NixOS setup note, thanks @qrzbing for the contribution! (#1390)
----------
## Bug Fixes
- 修复:在不同插件之间切换时,插件配置弹窗会正确刷新,避免沿用上一个插件的旧表单数据(#1394
- 修复:开启 beta 更新通道后,最新版本检查会正确比较正式版与 beta 版,避免更新提示不准确(#1396
## Other
- 更新:README 补充了 Nix/NixOS 相关说明,感谢 @qrzbing 的贡献!(#1390
+8 -5
View File
@@ -10,7 +10,8 @@ This guide describes how to generate a consolidated changelog for any PicGo rele
## Structure
- Target file: `changelog/X.Y.Z.md` (replace with the series version)
- Three top-level sections: `## Features`, `## Bug Fixes`, `## Other`
- Use these top-level sections when they contain items: `## Features`, `## Bug Fixes`, `## Other`
- Omit any empty section title entirely (do not output a heading with no bullets)
- Do **not** nest content under individual beta version headers; merge all items into these sections
- Keep items in chronological order (early → late) within each section, mirroring the beta sequence
- Keep inline issue references, thanks, and notes as-is
@@ -33,18 +34,20 @@ This guide describes how to generate a consolidated changelog for any PicGo rele
- Features ↔ “Feature(s)” or “Features” blocks
- Bug Fixes ↔ “Bug Fixes” blocks
- Other ↔ “Other”, “Notice”, or misc notes that are not features/bugs
- If one section has no items after merging, skip that section heading in both EN and ZH halves
3) Omit “国内可下载链接” (or any download links) entirely
4) Drop beta subheadings; keep only section-level bullets in chronological order
5) Ensure images remain adjacent to their bullets with indentation
6) After the English sections are complete, insert `----------` on its own line
7) Append the Chinese translation, preserving bullet order and images, under `## Features`, `## Bug Fixes`, `## Other` again (same section titles, just Chinese content; no “(Chinese)” suffix)
7) Append the Chinese translation, preserving bullet order and images, reusing only the section headings that appeared in English (same heading set/order; no “(Chinese)” suffix)
8) Save the result to `changelog/X.Y.Z.md`
## Quick checklist
- [ ] All features present with images kept
- [ ] All bug fixes present
- [ ] All Other notes present
- [ ] All non-empty Features items present with images kept
- [ ] All non-empty Bug Fixes items present
- [ ] All non-empty Other notes present
- [ ] No beta headers
- [ ] No download links
- [ ] No empty section headings
- [ ] Chronological ordering preserved
- [ ] Chinese translation present with matching bullets/images after `----------`
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/renderer/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
-216
View File
@@ -1,216 +0,0 @@
<template lang='pug'>
#app(v-cloak)
#header
.mask
img.logo(src="~icons/256x256.png", alt="PicGo")
h1.title PicGo
small(v-if="version") {{ version }}
h2.desc 图片上传+管理新体验
button.download(@click="goLink('https://github.com/Molunerfinn/picgo/releases')") 免费下载
button.download(@click="goLink('https://picgo.github.io/PicGo-Doc/guide/')") 查看文档
h3.desc
| 基于#[a(href="https://github.com/SimulatedGREG/electron-vue" target="_blank") electron-vue]开发
h3.desc
| 支持macOS,Windows,Linux
h3.desc
| 支持#[a(href="https://picgo.github.io/PicGo-Doc/guide/config.html#%E6%8F%92%E4%BB%B6%E8%AE%BE%E7%BD%AE%EF%BC%88v2-0%EF%BC%89" target="_blank") 插件系统]让PicGo更强大
#container.container-fluid
.row.ex-width
img.gallery.col-xs-10.col-xs-offset-1.col-md-offset-2.col-md-8(src="https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/first.png")
.row.ex-width.display-list
.display-list__item(v-for="(item, index) in itemList" :key="index" :class="{ 'o-item': index % 2 !== 0 }")
.col-xs-10.col-xs-offset-1.col-md-7.col-md-offset-0
img(:src="item.url")
.col-xs-10.col-xs-offset-1.col-md-5.col-md-offset-0.display-list__content
.display-list__title {{ item.title }}
.display-list__desc {{ item.desc }}
.row.ex-width.info
.col-xs-10.col-xs-offset-1
| &copy;2017 - {{ year }} #[a(href="https://github.com/Molunerfinn" target="_blank") Molunerfinn]
</template>
<script>
export default {
name: 'HomePage',
data () {
return {
version: '',
year: new Date().getFullYear(),
itemList: [
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/second.png',
title: '精致设计',
desc: 'macOS系统下,支持拖拽至menubar图标实现上传。menubar app 窗口显示最新上传的5张图片以及剪贴板里的图片。点击图片自动将上传的链接复制到剪贴板。(Windows平台不支持)'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/third.png',
title: 'Mini小窗',
desc: 'Windows以及Linux系统下提供一个mini悬浮窗用于用户拖拽上传,节约你宝贵的桌面空间。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/forth.png',
title: '便捷管理',
desc: '查看你的上传记录,重复使用更方便。支持点击图片大图查看。支持删除图片(仅本地记录),让界面更加干净。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/fifth.png',
title: '可选图床',
desc: '默认支持微博图床、七牛图床、腾讯云COS、又拍云、GitHub、SM.MS、阿里云OSS、Imgur。方便不同图床的上传需求。2.0版本开始更可以自己开发插件实现其他图床的上传需求。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/sixth.png',
title: '多样链接',
desc: '支持5种默认剪贴板链接格式,包括一种自定义格式,让你的文本编辑游刃有余。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/seventh.png',
title: '插件系统',
desc: '2.0版本开始支持插件系统,让PicGo发挥无限潜能,成为一个极致的效率工具。'
}
]
}
},
created () {
this.getVersion()
},
methods: {
goLink (link) {
window.open(link, '_blank')
},
async getVersion () {
const release = 'https://api.github.com/repos/Molunerfinn/PicGo/releases/latest'
const res = await this.$http.get(release)
this.version = res.data.name
}
}
}
</script>
<style lang='stylus'>
[v-cloak]
display none
*
box-sizing border-box
body,
html,
h1
margin 0
padding 0
font-family "Source Sans Pro","Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif
#app
position relative
.mask
position absolute
width 100%
height 100vh
top 0
left 0
background rgba(0,0,0, 0.7)
z-index -1
#header
height 100vh
width 100%
background-image url("https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/bg.jpeg")
background-attachment fixed
background-size cover
background-position center
text-align center
padding 15vh
position relative
z-index 2
.logo
width 120px
.title
color #4BA2E2
font-size 36px
font-weight 300
margin 10px auto
text-align center
small
margin-left 10px
font-size 14px
.desc
font-weight 400
margin 20px auto 10px
color #ddd
a
text-decoration none
color #4BA2E2
.download
display inline-block
line-height 1
white-space nowrap
cursor pointer
background transparent
border 1px solid #d8dce5
color #ddd
-webkit-appearance none
text-align center
box-sizing border-box
outline none
margin 20px 12px
transition .1s
font-weight 500
user-select none
padding 12px 20px
font-size 14px
border-radius 20px
padding 12px 23px
transition .2s all ease-in-out
&:hover
background #ddd
color rgba(0,0,0, 0.7)
#container
position relative
text-align center
margin-top -10vh
z-index 3
.gallery
margin-bottom 60px
cursor pointer
transition all .2s ease-in-out
&:hover
transform scale(1.05)
.display-list
&__item
padding 48px
text-align left
background #2E2E2E
overflow hidden
&.o-item
background #fff
.display-list__desc
color #2E2E2E
img
width 100%
cursor pointer
transition all .2s ease-in-out
&:hover
transform scale(1.05)
&__content
padding-top 120px
&__title
color #4BA2E2
font-size 50px
&__desc
color #fff
margin-top 20px
.info
padding 48px 0
background #2E2E2E
color #fff
a
text-decoration none
color #fff
@media (max-width: 768px)
#header
padding 10vh
#container
.display-list
&__item
padding 24px 12px
&__content
padding-top 30px
&__title
font-size 25px
&__desc
margin-top 12px
</style>
-10
View File
@@ -1,10 +0,0 @@
import Vue from 'vue'
import App from './APP.vue'
import 'melody.css'
import axios from 'axios'
Vue.prototype.$http = axios
new Vue({
render: h => h(App)
}).$mount('#app')
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>PicGo</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@ import dotenv from 'dotenv'
dotenv.config()
const shouldNotarize = process.env.SKIP_NOTARIZE !== 'true';
const shouldNotarize = process.env.SKIP_NOTARIZE !== 'true'
const config: Configuration = {
appId: 'com.molunerfinn.picgo',
+18 -5
View File
@@ -1,8 +1,9 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
// temp for webUtils
import electronRenderer from '@molunerfinn/vite-plugin-electron-renderer'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import { resolve } from 'path'
import { i18nTypesPlugin } from './scripts/vite-plugin-i18n-types'
const alias = {
'@': resolve(__dirname, 'src/renderer'),
@@ -27,7 +28,9 @@ export default defineConfig({
}
},
preload: {
plugins: [externalizeDepsPlugin()],
plugins: [externalizeDepsPlugin({
exclude: ['@picgo/i18n']
})],
resolve: { alias },
build: {
outDir: 'dist_electron/preload',
@@ -42,7 +45,17 @@ export default defineConfig({
root: 'src/renderer',
publicDir: resolve(__dirname, 'src/renderer/public'),
resolve: { alias },
plugins: [vue(), electronRenderer()],
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routesDirectory: './routes',
generatedRouteTree: './routeTree.gen.ts'
}),
react(),
i18nTypesPlugin(),
tailwindcss()
],
build: {
outDir: 'dist_electron/renderer',
rollupOptions: {
+38 -2
View File
@@ -5,9 +5,13 @@ const tsPlugin = require('@typescript-eslint/eslint-plugin')
const tsParser = require('@typescript-eslint/parser')
const importPlugin = require('eslint-plugin-import')
const promisePlugin = require('eslint-plugin-promise')
const reactHooksPlugin = require('eslint-plugin-react-hooks')
const reactRefreshModule = require('eslint-plugin-react-refresh')
const vuePlugin = require('eslint-plugin-vue')
const stylistic = require('@stylistic/eslint-plugin')
const reactRefreshPlugin = reactRefreshModule.default || reactRefreshModule.reactRefresh || reactRefreshModule
const isProduction = process.env.NODE_ENV === 'production'
const vueConfigs = vuePlugin.configs['flat/recommended'].map(config => ({
...config,
@@ -63,7 +67,10 @@ module.exports = [
'test/unit/coverage/**',
'test/unit/*.js',
'test/e2e/*.js',
'node_modules/**'
'node_modules/**',
'vitest.config.ts',
'src/renderer/temp-vue/**',
'src/renderer/routeTree.gen.ts'
]
},
{
@@ -78,6 +85,8 @@ module.exports = [
plugins: {
import: importPlugin,
promise: promisePlugin,
'react-hooks': reactHooksPlugin,
'react-refresh': reactRefreshPlugin,
'@stylistic': stylistic
},
settings: {
@@ -103,7 +112,8 @@ module.exports = [
'no-unused-vars': 'off',
'@stylistic/indent': ['error', 2],
'@stylistic/semi': ['error', 'never'],
'no-unexpected-multiline': 'error'
'no-unexpected-multiline': 'error',
...reactHooksPlugin.configs.recommended.rules
}
},
...vueConfigs,
@@ -121,6 +131,32 @@ module.exports = [
'no-undef': 'off'
}
},
{
files: ['src/renderer/**/*.{ts,tsx,vue}'],
rules: {
'no-restricted-imports': ['error', {
paths: [
{
name: 'electron',
allowTypeImports: true,
message: 'Use the preload bridge from @/utils/bridge in renderer runtime code.'
},
{
name: 'electron/renderer',
allowTypeImports: true,
message: 'Use the preload bridge from @/utils/bridge in renderer runtime code.'
}
]
}]
}
},
{
files: ['**/*.{tsx,jsx}'],
ignores: ['src/renderer/routes/**/*.tsx'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }]
}
},
{
files: ['**/*.d.ts'],
rules: {
+57 -13
View File
@@ -1,6 +1,6 @@
{
"name": "picgo",
"version": "2.4.3",
"version": "3.0.0",
"private": true,
"main": "dist_electron/main/index.js",
"description": "A powerful & simple image uploader for creators.",
@@ -9,62 +9,92 @@
"email": "marksz@teamsz.xyz"
},
"scripts": {
"build": "electron-vite build && electron-builder --config electron-builder.config.ts --publish never",
"build": "electron-vite build",
"build:win": "npm run build && electron-builder --config electron-builder.config.ts --win --publish never",
"build:mac": "npm run build && electron-builder --config electron-builder.config.ts --mac --publish never",
"build:linux": "npm run build && electron-builder --config electron-builder.config.ts --linux --publish never",
"build:local": "dotenv -e .env -- electron-vite build && electron-builder --config electron-builder.config.ts --publish never",
"lint": "pnpm lint:dpdm && eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
"lint": "pnpm lint:dpdm && pnpm lint:dpdm-fe && eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
"tsc": "tsc --noEmit",
"vue-tsc": "vue-tsc --noEmit",
"bump": "bump-version",
"cz": "git-cz",
"dev": "electron-vite dev",
"preview": "electron-vite preview",
"gen-i18n": "node ./scripts/gen-i18n-types.js",
"lint:fix": "eslint --fix --ext .js,.jsx,.ts,.tsx,.vue src/",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps",
"upload-dist": "node ./scripts/upload-dist.js",
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts",
"check": "pnpm run tsc && pnpm run lint",
"test": "vitest run src/__tests__",
"lint:dpdm-fe": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/renderer/main.tsx",
"check": "pnpm run tsc && pnpm run lint --fix",
"test": "vitest run",
"prepare": "husky",
"commitlint": "commitlint --edit"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@fontsource-variable/inter": "^5.2.8",
"@picgo/i18n": "^1.0.0",
"@picgo/store": "^2.1.0",
"@picgo/store": "^2.2.2",
"@picgo/video-duration": "^1.0.1",
"axios": "^0.19.0",
"@tanstack/history": "^1.161.4",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-router": "^1.166.2",
"@tanstack/router-plugin": "^1.166.2",
"@virtuoso.dev/masonry": "^1.4.2",
"ahooks": "^3.9.7",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clip-filepaths": "^0.3.0",
"clsx": "^2.1.1",
"comment-json": "^4.5.1",
"compare-versions": "^4.1.3",
"core-js": "^3.27.1",
"dayjs": "^1.11.19",
"dompurify": "^3.3.2",
"element-plus": "^2.3.7",
"epipebomb": "^1.0.0",
"fs-extra": "^10.0.0",
"js-yaml": "^4.1.0",
"i18next": "^25.8.14",
"immer": "^11.1.4",
"js-yaml": "^4.1.1",
"keycode": "^2.2.0",
"lodash": "^4.17.21",
"lodash-id": "^0.14.0",
"lowdb": "^1.0.0",
"lucide-react": "^0.577.0",
"marked": "^7.0.4",
"mime": "^4.1.0",
"mime-types": "^3.0.2",
"mitt": "^3.0.1",
"motion": "^12.35.1",
"multer": "^1.4.5-lts.1",
"picgo": "^1.8.1",
"next-themes": "^0.4.6",
"picgo": "^3.0.0",
"prismjs": "^1.30.0",
"qrcode.react": "^4.2.0",
"qrcode.vue": "^3.3.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.6",
"react-virtuoso": "^4.18.3",
"semver": "^7.7.3",
"shell-path": "2.1.0",
"sonner": "^2.0.7",
"systeminformation": "^5.27.14",
"tailwind-merge": "^3.5.0",
"tunnel": "^0.0.6",
"tw-animate-css": "^1.4.0",
"uuid": "^9.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vue3-lazyload": "^0.3.6",
"vue3-photo-preview": "^0.3.0",
"write-file-atomic": "^7.0.0"
"write-file-atomic": "^7.0.0",
"yaml": "^2.8.2",
"zustand": "^5.0.11"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.276.0",
@@ -73,14 +103,22 @@
"@molunerfinn/vite-plugin-electron-renderer": "^0.14.7",
"@picgo/bump-version": "^2.0.0",
"@stylistic/eslint-plugin": "^5.6.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/electron-devtools-installer": "^2.2.0",
"@types/fs-extra": "^9.0.13",
"@types/inquirer": "^6.5.0",
"@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.17.21",
"@types/lowdb": "^1.0.9",
"@types/mime-types": "^3.0.1",
"@types/multer": "^1.4.12",
"@types/node": "^20",
"@types/prismjs": "^1.26.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/request-promise-native": "^1.0.17",
"@types/semver": "^7.3.8",
"@types/tunnel": "^0.0.3",
@@ -88,6 +126,7 @@
"@types/write-file-atomic": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.14",
"commitizen": "^4.3.1",
@@ -103,16 +142,21 @@
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"postcss": "^8.4.23",
"shadcn": "^3.8.5",
"stylus": "^0.54.7",
"stylus-loader": "^3.0.2",
"tailwindcss": "^3.3.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.16"
"vitest": "^4.0.16",
"vue-tsc": "^3.2.3"
},
"commitlint": {
"extends": [
+3999 -373
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -7,11 +7,6 @@ onlyBuiltDependencies:
- husky
- vue-demi
# for multi-arch builds, include both x64 and arm64 versions of electron
# will be deprecated in future(use different arch machine to build different arch binaries)
supportedArchitectures:
os:
- current
cpu:
- x64
- arm64
cpu: [x64, arm64]
os: [current]
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
tailwindcss: {}
autoprefixer: {}
}
}
+305 -54
View File
@@ -3,7 +3,7 @@ ABOUT: About
OPEN_MAIN_WINDOW: Open Main Window
CHOOSE_DEFAULT_PICBED: Choose Default Picbed
OPEN_UPDATE_HELPER: Open Update Helper
PRIVACY_AGREEMENT: Privacy Agreement
PRIVACY_TERMS_AGREEMENT: Privacy & Terms Agreement
RELOAD_APP: Reload App
UPLOAD_FAILED: Upload Failed
UPLOAD_SUCCEED: Upload Succeed
@@ -22,6 +22,7 @@ DISABLE: Disable
CONFIG_THING: Config ${c}
FIND_NEW_VERSION: Find New Version
NO_MORE_NOTICE: No More Notice
MORE: More
SHOW_DEVTOOLS: Show Devtools
CURRENT_PICBED: Current Picbed
OPEN_TOOLBOX: Open Toolbox
@@ -29,11 +30,135 @@ OPEN_TOOLBOX: Open Toolbox
# ---renderer i18n begin---
CHOOSE_YOUR_DEFAULT_PICBED: "Choose ${d} as your default picbed:"
SIDEBAR_DASHBOARD: Dashboard
UPLOAD_AREA: Upload Area
GALLERY: Gallery
ALBUM: Album
ALBUM_PROVIDERS: Providers
PICBEDS_SETTINGS: Picbeds Settings
PICGO_SETTINGS: PicGo Settings
PLUGIN_SETTINGS: Plugins Settings
DASHBOARD_HISTORY_PANEL_TITLE: History Panel
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_DESCRIPTION: A cloud service built by PicGo, connecting all your devices.
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud Error
PICGO_CLOUD_LOADING: Loading PicGo Cloud status...
PICGO_CLOUD_NOT_LOGGED_IN: Not logged in to PicGo Cloud.
PICGO_CLOUD_LOGIN: Log In
PICGO_CLOUD_LOGOUT: Log Out
PICGO_CLOUD_CANCEL_LOGIN: Cancel Login
PICGO_CLOUD_RETRY: Retry
PICGO_CLOUD_CONFIG_SYNC: Config Sync
PICGO_CLOUD_LOGIN_IN_PROGRESS: Login in progress. Please finish login in your browser.
PICGO_CLOUD_LOGGED_IN_AS: "Logged in as: ${user}"
PICGO_CLOUD_OPEN: Open PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: Login timed out. Please try again.
PICGO_CLOUD_LOGIN_FAILED: Login failed. Please try again.
PICGO_CLOUD_AGREE_PREFIX: "I have read and agree to PicGo's "
PICGO_CLOUD_TERMS_OF_SERVICE: Terms of Service
PICGO_CLOUD_AGREE_AND: " and "
PICGO_CLOUD_PRIVACY_POLICY: Privacy Policy
PICGO_CLOUD_LOGIN_EXPIRED: Login expired. Please log in again.
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: Encryption mode
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: Auto
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: Server-side encryption
PICGO_CLOUD_ENCRYPTION_MODE_E2E: End-to-end encryption
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: "Auto: follow the cloud's last used encryption method (defaults to server-side)."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: "Server-side encryption: your config is encrypted on the server before being stored. No PIN required."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: "End-to-end encryption: use your PIN to encrypt/decrypt data. PicGo does not store your PIN; losing it means data cannot be recovered."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: Read full docs
PICGO_CLOUD_E2E_CHECKBOX_LABEL: Enable E2E encryption
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: Enable E2E Encryption
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: "E2E encryption requires you to keep your PIN safe. If you lose your PIN, the encrypted data cannot be recovered. PicGo does not store your PIN or any recovery data."
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: Remote config is E2E-encrypted. E2E has been enabled locally.
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: Set up E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: Enter E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "Incorrect PIN. Please try again (attempt ${retryCount})."
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: Confirm PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: Config sync succeeded.
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: Conflict detected. Please resolve the conflicts.
PICGO_CLOUD_CONFIG_SYNC_FAILED: Config sync failed.
PICGO_CLOUD_CONFIG_SYNC_ABORTED: Config sync cancelled.
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: Confirm switch encryption method?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "You are switching from \"${from}\" to \"${to}\".\n\nNote: Switching encryption modes will clear all your cloud history versions.\nThis is because older history versions cannot be decrypted or verified under the new mode.\nAfter switching, the system will immediately create a new backup as the starting point."
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: Confirm switch and clear history
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: Cancel
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: Encryption switch cancelled by user
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "Config sync failed: ${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: Too many incorrect PIN attempts. Please try again.
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: Local config is invalid. Please check your config file.
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: Config sync is in progress.
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: There is a pending conflict session. Please resolve conflicts first.
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: No conflict session found.
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: Please choose Local or Cloud for all conflict items.
PICGO_CLOUD_CONFIG_SYNC_STARTING: Config sync started...
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "Conflict Detected (${count})"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: Choose All Local
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: Choose All Cloud
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: Reset All
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: Local Version
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: Cloud Version
PICGO_CLOUD_CONFIG_SYNC_ABORT: Abort
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: Confirm & Sync
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (empty)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: Conflicts resolved.
PICGO_CLOUD_LAST_SYNC_TIME: "Last local sync time: ${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE: None
PICGO_CLOUD_LAST_SYNC_LABEL: Last sync
PICGO_CLOUD_PLAN_USAGE_TITLE: Plan & Usage
PICGO_CLOUD_PLAN_USAGE_DESC: View your current plan and usage overview.
PICGO_CLOUD_PLAN_PERIOD_LABEL: Plan Period
PICGO_CLOUD_STORAGE_LABEL: Storage
PICGO_CLOUD_FILES_LABEL: Files
PICGO_CLOUD_USAGE_PROGRESS: "${used} of ${total}"
PICGO_CLOUD_USAGE_UNLIMITED: Unlimited
PICGO_CLOUD_PLAN_PERIOD_RENEWS: "Renews on ${date}"
PICGO_CLOUD_PLAN_PERIOD_CANCELS: "Cancels on ${date}"
PICGO_CLOUD_PLAN_PERIOD_UNTIL: "Valid until ${date}"
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: Lifetime
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: Grace Until
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: "Your paid plan has expired and is now in the grace period. PicGo Cloud images remain accessible until this date, but quota is temporarily downgraded to free. Most paid features are temporarily unavailable. After this date, the account enters the frozen state."
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: Frozen Until
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: "Your account is frozen. PicGo Cloud images return errors. After this date, data may be cleaned up. Renew to restore access."
PICGO_CLOUD_QUOTA_DOWNGRADED: "Quota temporarily downgraded to ${plan} during grace period. Renew to restore."
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: Plan in grace period
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: "Your paid plan has expired and entered a ${days}-day grace period. Quota is temporarily downgraded to free, and most paid features are temporarily unavailable. Renew to restore full access."
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: Account frozen
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: "Your account is frozen — cloud images are temporarily inaccessible and data will be cleaned up in ${days} days. Renew now to restore access."
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: Data cleanup pending
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: Your account is pending cleanup and cloud data will be permanently deleted soon. Renew immediately if you want to keep your data.
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: Renew Now
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: Dismiss
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: Auto-import is paused while your plan is in grace or frozen period. It will resume after renewal.
PICGO_CLOUD_IMAGE_UNAVAILABLE: Image unavailable
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: This action is unavailable during the grace period. Please renew your plan and try again.
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: Your account is frozen. Please renew your plan to restore access.
PICGO_CLOUD_ERROR_IMPORT_DISABLED: Auto-import is disabled. Enable it in PicGo Cloud settings first.
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: Your current plan does not support this feature. Please upgrade and try again.
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: You have reached your plan quota. Upgrade to a higher plan to get more.
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: Quota is downgraded to free tier during grace period. Renew to restore.
PICGO_CLOUD_ERROR_PLAN_REQUIRED: This feature requires a paid plan.
PICGO_CLOUD_USAGE_LOAD_FAILED: Failed to load usage data.
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: Failed to load sync state.
PICGO_CLOUD_FREE_PLAN_BANNER: You're on a free plan. Upgrade to unlock more quota and advanced cloud features.
PICGO_CLOUD_VIEW_PLANS: View Plans
PICGO_CLOUD_CONFIG_SYNC_TITLE: Configuration Sync
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: Sync your settings across devices securely.
PICGO_CLOUD_SYNC_NOW: Sync Now
PICGO_CLOUD_SYNC_QUOTA_LABEL: Sync quota
PICGO_CLOUD_SYNC_QUOTA_TIP: "Your plan retains the most recent ${limit} configuration snapshots. Even at ${limit} of ${limit}, syncing remains available — older snapshots beyond the limit are pruned automatically after each sync."
PICGO_CLOUD_LOGIN_PANEL_TITLE: Sign in to PicGo Cloud
PICGO_CLOUD_LOGIN_PANEL_DESC: Sign in to use PicGo Cloud as PicGo's official image host and enable cloud features.
PICGO_CLOUD_LOGIN_FEATURES_TITLE: Cloud features
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: Official Image Host
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo's official image host with built-in album cloud sync.
PICGO_CLOUD_PAID_PLAN_BADGE: Paid plan
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: Restart Required?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: Some settings may require a restart to take effect. Restart now?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: Restart Now
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: Later
INPUT_BOX_CONFIRM_MISMATCH: The two inputs do not match.
PICGO_SPONSOR_TEXT: PicGo is a free software, if you like it, please don't forget to buy me a cup of coffee.
ALIPAY: Alipay
WECHATPAY: Wechat Pay
@@ -73,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: Custom Link Format
SETTINGS_SET_PROXY_AND_MIRROR: Set Proxy and Mirror
SETTINGS_SET_SERVER: Set Server
SETTINGS_CHECK_UPDATE: Check Update
SETTINGS_UPDATE_CHECK_RESULT: Latest version is ${version}, ${action}
SETTINGS_UPDATE_CHECK_NO_UPDATE: no update needed
SETTINGS_UPDATE_CHECK_CAN_UPDATE: update available
SETTINGS_OPEN_UPDATE_HELPER: Open Update Helper
SETTINGS_OPEN: Open
SETTINGS_CLOSE: Close
@@ -113,7 +241,7 @@ SETTINGS_LOG_LEVEL_WARN: Warn
SETTINGS_LOG_LEVEL_NONE: None
SETTINGS_RESULT: Result
SETTINGS_DEFAULT_PICBED: Default Picbed
SETTINGS_SET_DEFAULT_PICBED: Set Default Picbed
SETTINGS_SET_DEFAULT_PICBED: Set Default Uploader
SETTINGS_NOT_CONFIG_OPTIONS: Not Config Options
SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: Use Builtin Clipboard to Upload
SETTINGS_CHOOSE_LANGUAGE: Choose Language
@@ -184,21 +312,21 @@ SHORTCUT_DISABLE: Disable
SHORTCUT_EDIT: Edit
SHORTCUT_CHANGE_UPLOAD: Change Upload Shortcut
# gallery-page
GALLERY_URL_REWRITE_TITLE: Rewrite Selected Image URLs
GALLERY_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
GALLERY_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
# album-page
ALBUM_URL_REWRITE_TITLE: Rewrite Selected Image URLs
ALBUM_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
ALBUM_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
GALLERY_URL_REWRITE_APPLY_ONLY: Apply Only
GALLERY_URL_REWRITE_NO_CHANGES: No URLs were changed
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
ALBUM_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
ALBUM_URL_REWRITE_APPLY_ONLY: Apply Only
ALBUM_URL_REWRITE_NO_CHANGES: No URLs were changed
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
# tray-page
@@ -221,9 +349,12 @@ TIPS_INPUT_VALID_URL: Input valid URL
# plugins
PLUGIN_SEARCH_PLACEHOLDER: Search picgo plugins on npm, or click the button to view the awesome plugins list
PLUGIN_SEARCH_EXACT_MATCH: Exact plugin name match
PLUGIN_INSTALL: Install
PLUGIN_INSTALLING: Installing...
PLUGIN_INSTALLED: Installed
PLUGIN_DEPRECATED_BADGE: Deprecated
PLUGIN_DEPRECATED_TITLE: This plugin has been deprecated by the author
PLUGIN_DOING_SOMETHING: Doing...
PLUGIN_LIST: Plugin List
PLUGIN_IMPORT_LOCAL: Import Local Plugins
@@ -262,7 +393,7 @@ TOOLBOX: Toolbox
TOOLBOX_TITLE: Troubleshoot PicGo runtime issues
TOOLBOX_SUB_TITLE: Scan the following items immediately to fix usage issues
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: Check if the configuration file is damaged
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: Check if the album file is damaged
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: Check if the album file is damaged
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: Check if there is a problem with clipboard picture upload
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: Check if the proxy settings are normal
TOOLBOX_FIX_DONE_NEED_RELOAD: Repair completed, need to restart to take effect, restart or not
@@ -273,8 +404,8 @@ TOOLBOX_START_FIX: Start fixing
TOOLBOX_SUCCESS_TIPS: Congratulations, no problems were found
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: "The configuration file path is: ${path}"
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: The configuration file is damaged
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: "The album file path is: ${path}"
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: The album file is damaged
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: "The album file path is: ${path}"
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: The album file is damaged
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: Proxy settings normal
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: No proxy settings
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: Proxy settings incorrect
@@ -291,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: Skipped ${n} invalid URL(s), see logs for details
TIPS_TOO_MANY_URLS_CONFIRM: You are about to upload ${n} URLs at once. This may cause lag. It's recommended to upload in batches. Continue?
TIPS_NO_VALID_URLS: No valid URL found
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Please install Node.js and restart PicGo to continue
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: Plugin is trying to remove some images from the album gallery, continue?
TIPS_PLUGIN_OVERWRITE_GALLERY: Plugin is trying to overwrite the album gallery, continue?
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: Plugin is trying to remove some images from your album, continue?
TIPS_PLUGIN_OVERWRITE_ALBUM: Plugin is trying to overwrite your album, continue?
TIPS_UPLOAD_NOT_PICTURES: The latest clipboard item is not a picture
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo config file broken, has been restored to default
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo config file broken, has been restored to backup
@@ -304,39 +435,159 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: Custom link style modified successfully
TIPS_FIND_NEW_VERSION: Find new version ${v}update many new features, do you want to download the latest version?
TIPS_DELETE_UPLOADER_CONFIG: Are you sure you want to delete this config?
TIPS_COPY_UPLOADER_CONFIG: Are you sure you want to copy this config?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: Config name cannot be empty
TIPS_UPLOADER_CONFIG_NOT_FOUND: Config not found
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: Cannot delete the last config
# privacy
PRIVACY: >
This software respects and protects the personal privacy of all users who use the service. In order to provide you with more accurate and better services, this software will use and collect some of your behavioral information in accordance with the provisions of this Privacy Policy. When you agree to the software service use agreement, you are deemed to have agreed to the entire content of this privacy policy. This privacy policy is an integral part of the software service use agreement, and it will not be used if you do not agree.
This Agreement will be updated periodically.
1. Scope of application
a) When you use this software, this software will record some information about your operation behavior of this software, including but not limited to the time-consuming, type, quantity and other information of your use of this software to upload files.
2. Use of Information
a) After obtaining your usage data, the software will upload it to the data analysis server so as to provide you with better services after analyzing the data.
3. Information disclosure
a) This software will not disclose your information to untrusted third parties.
b) In accordance with the relevant provisions of the law, or the requirements of administrative or judicial institutions, disclose to third parties or administrative or judicial institutions;
4. Information Security
a) This software does not collect your personal information, key information and other private information, and the collected information is only used for improving the software, optimizing the experience, and understanding the daily activities of the software.
PRIVACY: "Please read and agree to the Privacy Policy ${privacyUrl} and Terms of Service ${termsUrl} before using."
PRIVACY_TIPS: Please agree the privacy policy to upload
QUIT: Quit
ALBUM_ALL_PHOTOS: All Photos
ALBUM_COLLECTIONS: Collections
ALBUM_TAGS: Tags
ALBUM_MENU: Menu
ALBUM_OPEN_INSPECTOR: Open Inspector
ALBUM_INSPECTOR_TITLE: Album Inspector
ALBUM_INSPECTOR_DESCRIPTION: Inspect and edit selected images.
ALBUM_CLEAR_SELECTION: Clear Selection
ALBUM_SELECTED_COUNT: ${count} selected
ALBUM_GRID_VIEW: Grid View
ALBUM_LIST_VIEW: List View
ALBUM_PREVIEW: Fullscreen Preview
ALBUM_PREVIEW_EMPTY: No images to preview
ALBUM_PREVIEW_PREV: Previous
ALBUM_PREVIEW_NEXT: Next
ALBUM_PREVIEW_CLOSE: Close preview
ALBUM_PREVIEW_COUNT: ${current} / ${total}
ALBUM_QUICK_ACTIONS: Quick Actions
ALBUM_EXPORT: Download
ALBUM_URL: URL
ALBUM_BATCH_REWRITE: Batch Rewrite
ALBUM_COLLECTION: Collection
ALBUM_ADD_TAG: Add tag
ALBUM_TAG_SUGGESTIONS: Suggestions
ALBUM_COLUMN_NAME: Name
ALBUM_COLUMN_PROVIDER: Provider
ALBUM_COLUMN_SIZE: Size
ALBUM_COLUMN_DATE: Date
ALBUM_ADD: Add
ALBUM_SOURCE_LOCAL: Local
ALBUM_SOURCE_CLOUD: Cloud
ALBUM_CLOUD_LOAD_FAILED: Failed to load cloud album
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: Login Required
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: Sign in to a paid PicGo Cloud account to use cloud album
ALBUM_CLOUD_LOGIN_BUTTON: Log In
ALBUM_CLOUD_UPGRADE_TITLE: Upgrade Required
ALBUM_CLOUD_UPGRADE_DESC: Cloud album requires a paid plan
ALBUM_CLOUD_UPGRADE_BUTTON: Upgrade
ALBUM_CLOUD_FEATURES_TITLE: Why Cloud Album?
ALBUM_CLOUD_FEATURE_SYNC: Access your upload history across all devices
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: Auto-import new uploads info (requires opt-in)
ALBUM_CLOUD_FEATURE_SECURE: One-click import local history to cloud
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: Import Local Records
ALBUM_CLOUD_IMPORT_GUIDE_DESC: Import your local upload history to cloud album. Only records are imported, not the actual image files.
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: Start Import
ALBUM_CLOUD_EMPTY_TITLE: No Items
ALBUM_CLOUD_EMPTY_DESC: Your cloud album is empty. Upload an image to get started.
ALBUM_CLOUD_IMPORTING: Importing to cloud album...
ALBUM_CLOUD_IMPORT_SUCCESS: Successfully imported ${num} items to cloud album
ALBUM_CLOUD_IMPORT_FAILED: Failed to import to cloud album
ALBUM_CLOUD_REFRESH: Refresh
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: Enable Auto-Import
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: This will enable auto-import so that future uploads are automatically recorded in your cloud album. All existing local records will also be imported now.
ALBUM_CLOUD_DELETE_FAILED: Failed to delete from cloud album
ALBUM_INSPECTOR_DETAILS_TITLE: Details
ALBUM_INSPECTOR_FILE_NAME: File name
ALBUM_INSPECTOR_UPLOADER: Uploader
ALBUM_INSPECTOR_CONTENT_TYPE: Content type
ALBUM_INSPECTOR_CREATED_AT: Created
ALBUM_INSPECTOR_UPDATED_AT: Updated
ALBUM_INSPECTOR_FILE_SIZE: Size
ALBUM_CLOUD_IMPORT_STATUS_TITLE: Cloud Album
ALBUM_CLOUD_IMPORTED: Imported to cloud
ALBUM_CLOUD_IMPORTED_TOOLTIP: This is a local marker. If the record was deleted from cloud, this status won't update. You can re-import if needed.
ALBUM_CLOUD_REIMPORT: Re-import
ALBUM_CLOUD_NATIVE: Uploaded via PicGo Cloud
ALBUM_CLOUD_IMPORT_BUTTON: Import to Cloud Album
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: Import ${num} item(s) to Cloud Album
ALBUM_CLOUD_ALL_IMPORTED: All in cloud album
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: Enable Auto Import
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: Auto import needs to be enabled before importing items to cloud album. Enable it now and continue?
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: Enable & Import
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: Successfully imported to cloud album
PICGO_CLOUD_AUTO_IMPORT_LABEL: Auto-import to Cloud Album
PICGO_CLOUD_AUTO_IMPORT_DESC: Auto-import upload records from other uploaders to cloud album (PicGo Cloud is saved by default)
SIDEBAR_PLUGINS: Plugins
SIDEBAR_SYSTEM: System
SIDEBAR_NOTIFICATIONS: Notifications
SIDEBAR_EXPAND: Click to Expand
SIDEBAR_COLLAPSE: Collapse Sidebar
HISTORY_PANEL_TITLE: History
HISTORY_PANEL_FILTER_PLACEHOLDER: Filter...
HISTORY_PANEL_TODAY: Today
HISTORY_PANEL_YESTERDAY: Yesterday
DASHBOARD_NO_VISIBLE_PROVIDERS: No visible providers
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: All providers are hidden. Open settings to change provider visibility.
DASHBOARD_OPEN_SETTINGS: Open Settings
DASHBOARD_DROP_IMAGES_HERE: Drop files here
DASHBOARD_OR: or
DASHBOARD_CLICK_TO_UPLOAD: Click to Upload
DASHBOARD_PASTE_FROM_CLIPBOARD: Paste from Clipboard
DASHBOARD_PASTE_FROM_URL: Paste from URL
DASHBOARD_CLIPBOARD: Clipboard
FIELD_IS_REQUIRED: ${field} is required
CONFIG_RENAME: Config Rename
SETTINGS_SECTION_GENERAL: General
SETTINGS_SECTION_APPEARANCE: Appearance
SETTINGS_SECTION_UPLOAD_WORKFLOW: Upload Workflow
SETTINGS_SECTION_NETWORK: Network
SETTINGS_SECTION_ADVANCED: Advanced
SETTINGS_SECTION_ABOUT: About
SETTINGS_NO_RESULTS_TITLE: No settings found
SETTINGS_NO_RESULTS_DESCRIPTION: Try another keyword or clear the search input.
SETTINGS_OPEN_SHORTCUTS: Shortcuts
SETTINGS_LINK_WEBSITE: Website
SETTINGS_LINK_GITHUB: GitHub
SETTINGS_LINK_DOCS: Documentation
SETTINGS_LINK_PRIVACY: Privacy Policy
SETTINGS_LINK_TERMS: Terms of Service
SETTINGS_APPEARANCE_MODE: Appearance
SETTINGS_APPEARANCE_MODE_LIGHT: Light
SETTINGS_APPEARANCE_MODE_DARK: Dark
SETTINGS_APPEARANCE_MODE_AUTO: Auto
COMMON_FOR_EXAMPLE: "Ex.: ${value}"
SETTINGS_SET_DEFAULT_CONFIG: Set as default
PROVIDER_DRAFT_CONFIG: Draft
PROVIDER_ACTIVE_UPLOADER_LABEL: Active
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} is the active uploader
PROVIDER_DEFAULT_CONFIG_LABEL: Default
PROVIDER_DEFAULT_CONFIG_TOOLTIP: Used when ${uploaderName} is active.
PROVIDER_SIDEBAR_EMPTY: No uploaders or configs found.
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} actions
PROVIDER_SIDEBAR_COLLAPSE: Collapse
PROVIDER_SIDEBAR_EXPAND: Expand
PROVIDER_CONFIG_ACTIONS: Config actions
PROVIDER_CREATE_CONFIG: Create config
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: This uploader has no configuration options, creating additional configs is unnecessary
PROVIDER_INSTALL_MORE_UPLOADERS: Install more uploaders
PROVIDER_NO_UPLOADER_SELECTED: No uploader selected.
PROVIDER_NO_CONFIG_YET: No config yet
PROVIDER_NO_CONFIG_DESCRIPTION: Create a new config for ${uploaderName} to start editing schema-based options.
PROVIDER_CONFIGURATION: Configuration
PROVIDER_UPDATED_AT_LABEL: Updated
PROVIDER_DELETE_CONFIG_HINT: Permanently remove this configuration.
UPLOADER_SWITCHER_SELECT_PROVIDER: Select provider
UPLOADER_SWITCHER_CONFIG: Config
UPLOADER_SWITCHER_NO_CONFIG: No config
ALBUM_URL_REWRITE_DESCRIPTION: Batch rewrite the URLs for selected images.
ALBUM_URL_REWRITE_CHANGED: Changed
ALBUM_URL_REWRITE_UNCHANGED: Unchanged
TRAY_ALREADY_UPLOAD_EMPTY: No uploaded images yet
PLUGIN_EMPTY: No plugins found.
PLUGIN_EMPTY_DESCRIPTION: Search npm and install plugins to get started.
PLUGIN_DETAIL: Detail
PLUGIN_NO_CONFIG_SCHEMA: This plugin does not expose config schema.
PLUGIN_NO_TRANSFORMER_SCHEMA: This plugin does not expose transformer schema.
PLUGIN_NO_README: No README available for this plugin.
NO_OPTIONS_FOUND: No options found.
+304 -53
View File
@@ -3,7 +3,7 @@ ABOUT: 关于
OPEN_MAIN_WINDOW: 打开主窗口
CHOOSE_DEFAULT_PICBED: 选择默认图床
OPEN_UPDATE_HELPER: 打开更新助手
PRIVACY_AGREEMENT: 隐私协议
PRIVACY_TERMS_AGREEMENT: 隐私与条款协议
RELOAD_APP: 重启应用
UPLOAD_SUCCEED: 上传成功
UPLOAD_FAILED: 上传失败
@@ -22,6 +22,7 @@ DISABLE: 禁用
CONFIG_THING: 配置${c}
FIND_NEW_VERSION: 发现新版本
NO_MORE_NOTICE: 以后不再提醒
MORE: 更多
SHOW_DEVTOOLS: 打开开发者工具
CURRENT_PICBED: 当前图床
OPEN_TOOLBOX: 打开修复工具箱
@@ -29,11 +30,135 @@ OPEN_TOOLBOX: 打开修复工具箱
# ---renderer i18n begin---
CHOOSE_YOUR_DEFAULT_PICBED: 选择 ${d} 作为你默认图床:
SIDEBAR_DASHBOARD: 仪表盘
UPLOAD_AREA: 上传区
GALLERY: 相册
ALBUM: 相册
ALBUM_PROVIDERS: 图床列表
PICBEDS_SETTINGS: 图床设置
PICGO_SETTINGS: PicGo设置
PLUGIN_SETTINGS: 插件设置
DASHBOARD_HISTORY_PANEL_TITLE: 历史面板
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_DESCRIPTION: PicGo 打造的云端服务,连接你的每一台设备。
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 错误
PICGO_CLOUD_LOADING: 正在加载 PicGo Cloud 状态…
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登录 PicGo Cloud
PICGO_CLOUD_LOGIN: 登录
PICGO_CLOUD_LOGOUT: 退出登录
PICGO_CLOUD_CANCEL_LOGIN: 取消登录
PICGO_CLOUD_RETRY: 重试
PICGO_CLOUD_CONFIG_SYNC: 配置同步
PICGO_CLOUD_LOGIN_IN_PROGRESS: 登录进行中,请在浏览器完成登录。
PICGO_CLOUD_LOGGED_IN_AS: "已登录:${user}"
PICGO_CLOUD_OPEN: 打开 PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: 登录超时,请重试。
PICGO_CLOUD_LOGIN_FAILED: 登录失败,请重试。
PICGO_CLOUD_AGREE_PREFIX: 我已阅读并同意 PicGo 的
PICGO_CLOUD_TERMS_OF_SERVICE: 服务条款
PICGO_CLOUD_AGREE_AND: 以及
PICGO_CLOUD_PRIVACY_POLICY: 隐私政策
PICGO_CLOUD_LOGIN_EXPIRED: 登录失效,请重新登录。
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 加密模式
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 自动
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: 服务端加密
PICGO_CLOUD_ENCRYPTION_MODE_E2E: 端到端加密
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: 自动:跟随云端上一次配置的加密方式(默认服务端加密)。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: 服务端加密:我们会在服务端对你的配置加密后再存储,不需要 PIN。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: 端到端加密:需要你输入 PIN 来加密/解密数据。PicGo 不存储 PIN,丢失将无法找回数据。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: 查看完整文档
PICGO_CLOUD_E2E_CHECKBOX_LABEL: 开启 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: 开启 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: E2E 加密需要你自行妥善保管 PIN。PIN 丢失将无法恢复加密数据。PicGo 不会存储 PIN 或任何恢复信息。
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: 检测到远端配置已开启 E2E 加密,已自动在本地开启 E2E。
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: 设置 E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: 输入 E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PIN 错误,请重试(第 ${retryCount} 次)。"
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: 确认 PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 配置同步成功。
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 检测到配置冲突,请选择解决方案。
PICGO_CLOUD_CONFIG_SYNC_FAILED: 配置同步失败。
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 已取消配置同步。
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 确认切换加密方式吗?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "您正在从“${from}”切换为“${to}”。\n\n注意:切换加密模式将清空您所有的云端历史版本记录。\n这是因为旧的历史版本无法在新模式下被解密或验证。\n切换后,系统将立即为您创建一份新的备份作为起点。"
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 确认切换并清空历史
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: 取消
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: 已取消切换加密方式
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "配置同步失败:${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PIN 错误次数过多,请稍后重试。
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: 本地配置文件格式错误,请检查配置文件。
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 配置同步进行中…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 存在未解决的冲突,请先处理。
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 未找到可处理的冲突会话。
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: 请为所有冲突项选择本地或云端。
PICGO_CLOUD_CONFIG_SYNC_STARTING: 配置同步已开始…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "冲突处理(${count} 项)"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: 全选本地
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: 全选云端
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: 重置全部
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: 本地版本
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: 云端版本
PICGO_CLOUD_CONFIG_SYNC_ABORT: 放弃同步
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 确认并同步
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置冲突已解决。
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步时间:${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE:
PICGO_CLOUD_LAST_SYNC_LABEL: 上次同步
PICGO_CLOUD_PLAN_USAGE_TITLE: 套餐与用量
PICGO_CLOUD_PLAN_USAGE_DESC: 查看当前套餐与用量概览。
PICGO_CLOUD_PLAN_PERIOD_LABEL: 套餐周期
PICGO_CLOUD_STORAGE_LABEL: 存储空间
PICGO_CLOUD_FILES_LABEL: 文件数
PICGO_CLOUD_USAGE_PROGRESS: "${used} / ${total}"
PICGO_CLOUD_USAGE_UNLIMITED: 无限
PICGO_CLOUD_PLAN_PERIOD_RENEWS: 续费日 ${date}
PICGO_CLOUD_PLAN_PERIOD_CANCELS: 失效日 ${date}
PICGO_CLOUD_PLAN_PERIOD_UNTIL: 有效期至 ${date}
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 长期有效
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 宽限期至
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: 付费套餐已过期,目前处于宽限期。该日期前 PicGo Cloud 图片仍可正常访问,但配额已临时降级到免费版。大部分付费功能将暂时不可使用。过期后账户将进入冻结状态。
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 冻结期至
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: 账户已冻结,PicGo Cloud 图片无法访问。该日期后数据可能被清理,请尽快续费以恢复权益。
PICGO_CLOUD_QUOTA_DOWNGRADED: 宽限期内配额已临时降级到 ${plan},续费后将恢复
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: 套餐已进入宽限期
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: 你的付费套餐已过期,目前进入 ${days} 天宽限期。期间配额已临时降级到免费版,大部分付费功能将暂时不可使用。请尽快续费以恢复完整权益。
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: 账户已冻结
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: 你的账户已进入冻结期,云端图片暂时无法访问,距离数据被清理还剩 ${days} 天。请立即续费以恢复访问。
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: 数据即将清理
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: 你的账户已进入待清理状态,云端数据即将被永久删除。如需保留数据,请立即续费。
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 立即续费
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 关闭
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: 套餐处于宽限期或冻结期,自动导入已暂停。续费后将恢复。
PICGO_CLOUD_IMAGE_UNAVAILABLE: 图片暂不可用
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 套餐处于宽限期,此操作暂不可用,请续费后重试。
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: 账户已冻结,请续费以恢复使用。
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 自动导入已被关闭,请先在 PicGo Cloud 设置中启用。
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 当前套餐不支持此功能,请升级后重试。
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: 已达套餐配额上限,升级到更高套餐可获得更多配额。
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 宽限期内配额已降级到免费版,请续费以恢复完整配额。
PICGO_CLOUD_ERROR_PLAN_REQUIRED: 此功能需要付费套餐。
PICGO_CLOUD_USAGE_LOAD_FAILED: 用量数据加载失败
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 同步状态加载失败
PICGO_CLOUD_FREE_PLAN_BANNER: 你当前使用免费套餐,升级后可解锁更多配额和高级云功能。
PICGO_CLOUD_VIEW_PLANS: 查看套餐
PICGO_CLOUD_CONFIG_SYNC_TITLE: 配置同步
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: 在不同设备间安全同步你的配置。
PICGO_CLOUD_SYNC_NOW: 立即同步
PICGO_CLOUD_SYNC_QUOTA_LABEL: 同步配额
PICGO_CLOUD_SYNC_QUOTA_TIP: "当前套餐保留最近 ${limit} 份历史版本。即使显示 ${limit} / ${limit},依然可以同步,每次同步后会自动清理超出范围的旧版本。"
PICGO_CLOUD_LOGIN_PANEL_TITLE: 登录 PicGo Cloud
PICGO_CLOUD_LOGIN_PANEL_DESC: 登录即可使用 PicGo 官方图床,并启用云端相关功能。
PICGO_CLOUD_LOGIN_FEATURES_TITLE: 云端功能
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 官方图床
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo 官方图床,内置相册云同步。
PICGO_CLOUD_PAID_PLAN_BADGE: 付费套餐
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重启吗?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重启后生效,是否立即重启?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重启
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: 稍后
INPUT_BOX_CONFIRM_MISMATCH: 两次输入不一致,请重新输入。
PICGO_SPONSOR_TEXT: PicGo是免费开源的软件,如果你喜欢它,对你有帮助,不妨请我喝杯咖啡?
ALIPAY: 支付宝
WECHATPAY: 微信支付
@@ -73,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: 自定义链接格式
SETTINGS_SET_PROXY_AND_MIRROR: 设置代理和镜像地址
SETTINGS_SET_SERVER: 设置Server
SETTINGS_CHECK_UPDATE: 检查更新
SETTINGS_UPDATE_CHECK_RESULT: 最新版本为 ${version}${action}
SETTINGS_UPDATE_CHECK_NO_UPDATE: 无需更新
SETTINGS_UPDATE_CHECK_CAN_UPDATE: 可以更新
SETTINGS_OPEN_UPDATE_HELPER: 打开更新助手
SETTINGS_OPEN:
SETTINGS_CLOSE:
@@ -184,21 +312,21 @@ SHORTCUT_DISABLE: 禁用
SHORTCUT_EDIT: 编辑
SHORTCUT_CHANGE_UPLOAD: 修改上传快捷键
# gallery-page
GALLERY_URL_REWRITE_TITLE: 重写选中图片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
# album-page
ALBUM_URL_REWRITE_TITLE: 重写选中图片 URL
ALBUM_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
GALLERY_URL_REWRITE_APPLY_ONLY: 仅应用
GALLERY_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
ALBUM_URL_REWRITE_APPLY_ONLY: 仅应用
ALBUM_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
# tray-page
@@ -221,9 +349,12 @@ TIPS_INPUT_VALID_URL: 请输入合法的URL
# plugins
PLUGIN_SEARCH_PLACEHOLDER: 搜索npm上的PicGo插件,或者点击上方按钮查看优秀插件列表
PLUGIN_SEARCH_EXACT_MATCH: 插件名精确匹配
PLUGIN_INSTALL: 安装
PLUGIN_INSTALLING: 安装中
PLUGIN_INSTALLED: 已安装
PLUGIN_DEPRECATED_BADGE: 已弃用
PLUGIN_DEPRECATED_TITLE: 此插件已被作者标记为弃用
PLUGIN_DOING_SOMETHING: 进行中
PLUGIN_LIST: 插件列表
PLUGIN_IMPORT_LOCAL: 导入本地插件
@@ -262,7 +393,7 @@ TOOLBOX: 工具箱
TOOLBOX_TITLE: 排查 PicGo 运行时问题
TOOLBOX_SUB_TITLE: 立即扫描以下项目,修复使用问题
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 检查配置文件是否损坏
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: 检查相册文件是否损坏
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: 检查相册文件是否损坏
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: 检查剪贴板图片上传是否存在问题
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: 检查代理设置是否正常
TOOLBOX_FIX_DONE_NEED_RELOAD: 修复完成,需要重启生效,是否重启
@@ -273,8 +404,8 @@ TOOLBOX_START_FIX: 开始修复
TOOLBOX_SUCCESS_TIPS: 恭喜你,没有检查出问题
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: 配置文件路径是:${path}
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 配置文件已损坏
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: 相册文件路径是:${path}
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: 相册文件已损坏
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: 相册文件路径是:${path}
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: 相册文件已损坏
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: 代理设置正常
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: 无代理设置
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: 代理设置不正确
@@ -291,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: 已跳过 ${n} 条非法 URL,请查看日志了解
TIPS_TOO_MANY_URLS_CONFIRM: 你将一次上传 ${n} 条 URL,可能会引起卡顿,建议分批上传。是否继续?
TIPS_NO_VALID_URLS: 未检测到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 请安装Node.js并重启PicGo再继续操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在试图删除一些相册图片,是否继续
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在试图覆盖相册列表,是否继续
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: 有插件正在试图删除一些相册图片,是否继续
TIPS_PLUGIN_OVERWRITE_ALBUM: 有插件正在试图覆盖相册列表,是否继续
TIPS_UPLOAD_NOT_PICTURES: 剪贴板最新的一条记录不是图片
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo 配置文件损坏,已经恢复为默认配置
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo 配置文件损坏,已经恢复为备份配置
@@ -304,39 +435,159 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: 自定义链接格式已经修改成功
TIPS_FIND_NEW_VERSION: 发现新版本${v},更新了很多功能,是否去下载最新的版本?
TIPS_DELETE_UPLOADER_CONFIG: 是否要删除这个配置?
TIPS_COPY_UPLOADER_CONFIG: 是否要复制这个配置?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 配置名称不能为空
TIPS_UPLOADER_CONFIG_NOT_FOUND: 未找到该配置
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 无法删除最后一个配置
# privacy
PRIVACY: >
本软件尊重并保护所有使用服务用户的个人隐私权。为了给您提供更准确、更优质的服务,本软件会按照本隐私权政策的规定使用和收集您的一些行为信息。您在同意本软件服务使用协议之时,即视为您已经同意本隐私权政策全部内容。本隐私权政策属于本软件服务使用协议不可分割的一部分,如果不同意将无法使用。本协议会定期更新。
1.适用范围
a)在您使用本软件时,本软件会记录的您对本软件的一些操作行为信息,包括但不限于您使用本软件进行文件上传的耗时、类型、数量等信息。
2.信息的使用
a)在获得您的使用数据之后,本软件会将其上传至数据分析服务器,以便分析数据后,提供给您更好的服务。
3.信息披露
a)本软件不会将您的信息披露给不受信任的第三方。
b)根据法律的有关规定,或者行政或司法机构的要求,向第三方或者行政、司法机构披露;
4.信息安全
a)本软件不会收集您的个人信息、密钥信息等隐私信息,所收集的信息仅仅作为改善软件、优化体验、了解软件日活等用途。
PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款 ${termsUrl},同意后方可使用。"
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部照片
ALBUM_COLLECTIONS: 合集
ALBUM_TAGS: 标签
ALBUM_MENU: 菜单
ALBUM_OPEN_INSPECTOR: 打开检查器
ALBUM_INSPECTOR_TITLE: 相册检查器
ALBUM_INSPECTOR_DESCRIPTION: 查看并编辑已选图片。
ALBUM_CLEAR_SELECTION: 取消选择
ALBUM_SELECTED_COUNT: 已选 ${count} 项
ALBUM_GRID_VIEW: 网格视图
ALBUM_LIST_VIEW: 列表视图
ALBUM_PREVIEW: 全屏预览
ALBUM_PREVIEW_EMPTY: 没有可预览的图片
ALBUM_PREVIEW_PREV: 上一张
ALBUM_PREVIEW_NEXT: 下一张
ALBUM_PREVIEW_CLOSE: 关闭预览
ALBUM_PREVIEW_COUNT: ${current} / ${total}
ALBUM_QUICK_ACTIONS: 快速操作
ALBUM_EXPORT: 下载
ALBUM_URL: URL
ALBUM_BATCH_REWRITE: 批量重写
ALBUM_COLLECTION: 合集
ALBUM_ADD_TAG: 添加标签
ALBUM_TAG_SUGGESTIONS: 推荐标签
ALBUM_COLUMN_NAME: 名称
ALBUM_COLUMN_PROVIDER: 图床
ALBUM_COLUMN_SIZE: 大小
ALBUM_COLUMN_DATE: 日期
ALBUM_ADD: 添加
ALBUM_SOURCE_LOCAL: 本地
ALBUM_SOURCE_CLOUD: 云端
ALBUM_CLOUD_LOAD_FAILED: 加载云端相册失败
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: 需要登录
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: 登录 PicGo Cloud 付费账户以使用云端相册
ALBUM_CLOUD_LOGIN_BUTTON: 前往登录
ALBUM_CLOUD_UPGRADE_TITLE: 需要升级
ALBUM_CLOUD_UPGRADE_DESC: 云端相册需要付费套餐
ALBUM_CLOUD_UPGRADE_BUTTON: 升级套餐
ALBUM_CLOUD_FEATURES_TITLE: 为什么使用云端相册?
ALBUM_CLOUD_FEATURE_SYNC: 多设备访问上传历史记录
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 上传后自动导入相册记录到云端(需开启该功能)
ALBUM_CLOUD_FEATURE_SECURE: 一键导入本地历史到云端
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: 导入本地记录
ALBUM_CLOUD_IMPORT_GUIDE_DESC: 将本地上传记录导入到云端相册。仅导入记录,图片文件本身不会被上传。
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: 开始导入
ALBUM_CLOUD_EMPTY_TITLE: 暂无内容
ALBUM_CLOUD_EMPTY_DESC: 云端相册为空,上传图片即可开始使用。
ALBUM_CLOUD_IMPORTING: 正在导入到云端相册...
ALBUM_CLOUD_IMPORT_SUCCESS: 成功导入 ${num} 条记录到云端相册
ALBUM_CLOUD_IMPORT_FAILED: 导入云端相册失败
ALBUM_CLOUD_REFRESH: 刷新
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 开启自动导入
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: 此操作将开启自动导入功能,后续上传的图片记录会自动同步到云端相册。同时会将现有的本地记录一并导入。
ALBUM_CLOUD_DELETE_FAILED: 从云端相册删除失败
ALBUM_INSPECTOR_DETAILS_TITLE: 详情
ALBUM_INSPECTOR_FILE_NAME: 文件名
ALBUM_INSPECTOR_UPLOADER: 上传器
ALBUM_INSPECTOR_CONTENT_TYPE: 内容类型
ALBUM_INSPECTOR_CREATED_AT: 创建时间
ALBUM_INSPECTOR_UPDATED_AT: 更新时间
ALBUM_INSPECTOR_FILE_SIZE: 文件大小
ALBUM_CLOUD_IMPORT_STATUS_TITLE: 云端相册
ALBUM_CLOUD_IMPORTED: 已导入云端
ALBUM_CLOUD_IMPORTED_TOOLTIP: 这是本地标记,不会与云端同步。如果云端记录已被删除,此状态不会更新。你可以重新导入。
ALBUM_CLOUD_REIMPORT: 重新导入
ALBUM_CLOUD_NATIVE: 通过 PicGo Cloud 上传
ALBUM_CLOUD_IMPORT_BUTTON: 导入到云端相册
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: 导入 ${num} 条记录到云端相册
ALBUM_CLOUD_ALL_IMPORTED: 已全部在云端相册中
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 需要开启自动导入
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: 导入到云端相册前需要先开启自动导入功能。是否立即开启并继续导入?
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 开启并导入
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: 已成功导入到云端相册
PICGO_CLOUD_AUTO_IMPORT_LABEL: 自动导入云端相册
PICGO_CLOUD_AUTO_IMPORT_DESC: 自动将其他图床的上传记录导入云端相册(PicGo Cloud 默认已写入)
SIDEBAR_PLUGINS: 插件
SIDEBAR_SYSTEM: 系统
SIDEBAR_NOTIFICATIONS: 通知
SIDEBAR_EXPAND: 点击展开
SIDEBAR_COLLAPSE: 收起侧边栏
HISTORY_PANEL_TITLE: 历史记录
HISTORY_PANEL_FILTER_PLACEHOLDER: 筛选...
HISTORY_PANEL_TODAY: 今天
HISTORY_PANEL_YESTERDAY: 昨天
DASHBOARD_NO_VISIBLE_PROVIDERS: 没有可见图床
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: 当前所有图床都被隐藏了,请前往设置调整可见图床。
DASHBOARD_OPEN_SETTINGS: 打开设置
DASHBOARD_DROP_IMAGES_HERE: 将文件拖拽到此处
DASHBOARD_OR:
DASHBOARD_CLICK_TO_UPLOAD: 点击上传
DASHBOARD_PASTE_FROM_CLIPBOARD: 从剪贴板粘贴
DASHBOARD_PASTE_FROM_URL: 从 URL 粘贴
DASHBOARD_CLIPBOARD: 剪贴板
FIELD_IS_REQUIRED: ${field} 为必填项
CONFIG_RENAME: 配置改名
SETTINGS_SECTION_GENERAL: 通用
SETTINGS_SECTION_APPEARANCE: 外观
SETTINGS_SECTION_UPLOAD_WORKFLOW: 上传流程
SETTINGS_SECTION_NETWORK: 网络
SETTINGS_SECTION_ADVANCED: 高级
SETTINGS_SECTION_ABOUT: 关于
SETTINGS_NO_RESULTS_TITLE: 没有匹配的设置项
SETTINGS_NO_RESULTS_DESCRIPTION: 请尝试其他关键词,或清空搜索条件。
SETTINGS_OPEN_SHORTCUTS: 快捷键
SETTINGS_LINK_WEBSITE: 官网
SETTINGS_LINK_GITHUB: GitHub
SETTINGS_LINK_DOCS: 文档
SETTINGS_LINK_PRIVACY: 隐私协议
SETTINGS_LINK_TERMS: 服务条款
SETTINGS_APPEARANCE_MODE: 主题模式
SETTINGS_APPEARANCE_MODE_LIGHT: 浅色
SETTINGS_APPEARANCE_MODE_DARK: 深色
SETTINGS_APPEARANCE_MODE_AUTO: 跟随系统
COMMON_FOR_EXAMPLE: 例如:${value}
SETTINGS_SET_DEFAULT_CONFIG: 设为默认配置
PROVIDER_DRAFT_CONFIG: 草稿
PROVIDER_ACTIVE_UPLOADER_LABEL: 当前
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} 是当前使用的上传器
PROVIDER_DEFAULT_CONFIG_LABEL: 默认
PROVIDER_DEFAULT_CONFIG_TOOLTIP: 当 ${uploaderName} 处于当前激活状态时使用。
PROVIDER_SIDEBAR_EMPTY: 未找到图床或配置。
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} 操作
PROVIDER_SIDEBAR_COLLAPSE: 收起
PROVIDER_SIDEBAR_EXPAND: 展开
PROVIDER_CONFIG_ACTIONS: 配置操作
PROVIDER_CREATE_CONFIG: 新建配置
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: 该上传器没有配置项,无需新建配置
PROVIDER_INSTALL_MORE_UPLOADERS: 安装更多图床
PROVIDER_NO_UPLOADER_SELECTED: 尚未选择图床。
PROVIDER_NO_CONFIG_YET: 暂无配置
PROVIDER_NO_CONFIG_DESCRIPTION: 为 ${uploaderName} 新建一个配置后即可开始编辑配置项。
PROVIDER_CONFIGURATION: 配置项
PROVIDER_UPDATED_AT_LABEL: 更新时间
PROVIDER_DELETE_CONFIG_HINT: 该操作会永久删除当前配置。
UPLOADER_SWITCHER_SELECT_PROVIDER: 选择图床
UPLOADER_SWITCHER_CONFIG: 配置
UPLOADER_SWITCHER_NO_CONFIG: 暂无配置
ALBUM_URL_REWRITE_DESCRIPTION: 批量重写所选图片的 URL。
ALBUM_URL_REWRITE_CHANGED: 已修改
ALBUM_URL_REWRITE_UNCHANGED: 未修改
TRAY_ALREADY_UPLOAD_EMPTY: 暂无已上传图片
PLUGIN_EMPTY: 暂未发现插件
PLUGIN_EMPTY_DESCRIPTION: 可以先在上方搜索并安装一些插件
PLUGIN_DETAIL: 详情
PLUGIN_NO_CONFIG_SCHEMA: 该插件未暴露 config 配置项。
PLUGIN_NO_TRANSFORMER_SCHEMA: 该插件未暴露 transformer 配置项。
PLUGIN_NO_README: 该插件暂无 README 内容。
NO_OPTIONS_FOUND: 没有可选项
+304 -53
View File
@@ -3,7 +3,7 @@ ABOUT: 關於
OPEN_MAIN_WINDOW: 打開主視窗
CHOOSE_DEFAULT_PICBED: 選擇預設圖床
OPEN_UPDATE_HELPER: 開啟更新助手
PRIVACY_AGREEMENT: 隱私協議
PRIVACY_TERMS_AGREEMENT: 隱私與條款協議
RELOAD_APP: 重啟程式
UPLOAD_SUCCEED: 上傳成功
UPLOAD_FAILED: 上傳失敗
@@ -22,6 +22,7 @@ DISABLE: 禁用
CONFIG_THING: 設定${c}
FIND_NEW_VERSION: 發現新版本
NO_MORE_NOTICE: 以後不再提醒
MORE: 更多
SHOW_DEVTOOLS: 開啟開發者工具
CURRENT_PICBED: 當前圖床
OPEN_TOOLBOX: 開啟修復工具箱
@@ -29,11 +30,135 @@ OPEN_TOOLBOX: 開啟修復工具箱
# ---renderer i18n begin---
CHOOSE_YOUR_DEFAULT_PICBED: 選擇 ${d} 作為你的預設圖床:
SIDEBAR_DASHBOARD: 儀表板
UPLOAD_AREA: 上傳區
GALLERY: 相簿
ALBUM: 相簿
ALBUM_PROVIDERS: 圖床列表
PICBEDS_SETTINGS: 圖床設定
PICGO_SETTINGS: PicGo設定
PLUGIN_SETTINGS: 插件設定
DASHBOARD_HISTORY_PANEL_TITLE: 歷史面板
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_DESCRIPTION: PicGo 打造的雲端服務,連接你的每一台裝置。
PICGO_CLOUD_BRAND_NAME: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 錯誤
PICGO_CLOUD_LOADING: 正在載入 PicGo Cloud 狀態…
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登入 PicGo Cloud
PICGO_CLOUD_LOGIN: 登入
PICGO_CLOUD_LOGOUT: 登出
PICGO_CLOUD_CANCEL_LOGIN: 取消登入
PICGO_CLOUD_RETRY: 重試
PICGO_CLOUD_CONFIG_SYNC: 配置同步
PICGO_CLOUD_LOGIN_IN_PROGRESS: 登入進行中,請在瀏覽器完成登入。
PICGO_CLOUD_LOGGED_IN_AS: "已登入:${user}"
PICGO_CLOUD_OPEN: 打開 PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: 登入逾時,請重試。
PICGO_CLOUD_LOGIN_FAILED: 登入失敗,請重試。
PICGO_CLOUD_AGREE_PREFIX: 我已閱讀並同意 PicGo 的
PICGO_CLOUD_TERMS_OF_SERVICE: 服務條款
PICGO_CLOUD_AGREE_AND: 以及
PICGO_CLOUD_PRIVACY_POLICY: 隱私政策
PICGO_CLOUD_LOGIN_EXPIRED: 登入失效,請重新登入。
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 加密模式
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 自動
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: 服務端加密
PICGO_CLOUD_ENCRYPTION_MODE_E2E: 端到端加密
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: 自動:跟隨雲端上一次配置的加密方式(預設服務端加密)。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: 服務端加密:我們會在服務端對你的配置加密後再儲存,不需要 PIN。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: 端到端加密:需要你輸入 PIN 來加密/解密資料。PicGo 不儲存 PIN,遺失將無法找回資料。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: 查看完整文檔
PICGO_CLOUD_E2E_CHECKBOX_LABEL: 開啟 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: 開啟 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: E2E 加密需要你自行妥善保管 PIN。PIN 遺失將無法恢復加密資料。PicGo 不會儲存 PIN 或任何恢復資訊。
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: 偵測到遠端配置已開啟 E2E 加密,已自動在本地開啟 E2E。
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: 設定 E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: 輸入 E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PIN 錯誤,請重試(第 ${retryCount} 次)。"
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: 確認 PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 配置同步成功。
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 偵測到配置衝突,請選擇解決方案。
PICGO_CLOUD_CONFIG_SYNC_FAILED: 配置同步失敗。
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 已取消配置同步。
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 確認切換加密方式嗎?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "您正在從「${from}」切換為「${to}」。\n\n注意:切換加密模式將清空您所有的雲端歷史版本記錄。\n這是因為舊的歷史版本無法在新模式下被解密或驗證。\n切換後,系統將立即為您建立一份新的備份作為起點。"
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 確認切換並清空歷史
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: 取消
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: 已取消切換加密方式
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "配置同步失敗:${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PIN 錯誤次數過多,請稍後重試。
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: 本地配置檔格式錯誤,請檢查配置檔。
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 配置同步進行中…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 存在未解決的衝突,請先處理。
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 未找到可處理的衝突工作階段。
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: 請為所有衝突項選擇本地或雲端。
PICGO_CLOUD_CONFIG_SYNC_STARTING: 配置同步已開始…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "衝突處理(${count} 項)"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: 全選本地
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: 全選雲端
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: 重置全部
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: 本地版本
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: 雲端版本
PICGO_CLOUD_CONFIG_SYNC_ABORT: 放棄同步
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 確認並同步
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置衝突已解決。
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步時間:${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE:
PICGO_CLOUD_LAST_SYNC_LABEL: 上次同步
PICGO_CLOUD_PLAN_USAGE_TITLE: 方案與用量
PICGO_CLOUD_PLAN_USAGE_DESC: 查看目前方案與用量概覽。
PICGO_CLOUD_PLAN_PERIOD_LABEL: 方案週期
PICGO_CLOUD_STORAGE_LABEL: 儲存空間
PICGO_CLOUD_FILES_LABEL: 檔案數
PICGO_CLOUD_USAGE_PROGRESS: "${used} / ${total}"
PICGO_CLOUD_USAGE_UNLIMITED: 無限
PICGO_CLOUD_PLAN_PERIOD_RENEWS: 續費日 ${date}
PICGO_CLOUD_PLAN_PERIOD_CANCELS: 失效日 ${date}
PICGO_CLOUD_PLAN_PERIOD_UNTIL: 有效期至 ${date}
PICGO_CLOUD_PLAN_PERIOD_LIFETIME: 長期有效
PICGO_CLOUD_PLAN_PERIOD_GRACE_LABEL: 寬限期至
PICGO_CLOUD_PLAN_PERIOD_GRACE_TOOLTIP: 付費套餐已過期,目前處於寬限期。該日期前 PicGo Cloud 圖片仍可正常存取,但配額已暫時降級到免費版。大部分付費功能將暫時不可使用。過期後帳戶將進入凍結狀態。
PICGO_CLOUD_PLAN_PERIOD_FROZEN_LABEL: 凍結期至
PICGO_CLOUD_PLAN_PERIOD_FROZEN_TOOLTIP: 帳戶已凍結,PicGo Cloud 圖片無法存取。該日期後資料可能被清理,請儘快續費以恢復權益。
PICGO_CLOUD_QUOTA_DOWNGRADED: 寬限期內配額已暫時降級到 ${plan},續費後將恢復
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_TITLE: 套餐已進入寬限期
PICGO_CLOUD_LIFECYCLE_BANNER_GRACE_DESC: 你的付費套餐已過期,目前進入 ${days} 天寬限期。期間配額已暫時降級到免費版,大部分付費功能將暫時不可使用。請儘快續費以恢復完整權益。
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_TITLE: 帳戶已凍結
PICGO_CLOUD_LIFECYCLE_BANNER_FROZEN_DESC: 你的帳戶已進入凍結期,雲端圖片暫時無法存取,距離資料被清理還剩 ${days} 天。請立即續費以恢復存取。
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_TITLE: 資料即將清理
PICGO_CLOUD_LIFECYCLE_BANNER_PENDING_CLEANUP_DESC: 你的帳戶已進入待清理狀態,雲端資料即將被永久刪除。如需保留資料,請立即續費。
PICGO_CLOUD_LIFECYCLE_BANNER_CTA: 立即續費
PICGO_CLOUD_LIFECYCLE_BANNER_DISMISS: 關閉
PICGO_CLOUD_AUTO_IMPORT_DISABLED_BY_LIFECYCLE: 套餐處於寬限期或凍結期,自動匯入已暫停。續費後將恢復。
PICGO_CLOUD_IMAGE_UNAVAILABLE: 圖片暫時無法顯示
PICGO_CLOUD_ERROR_GRACE_RESTRICTED: 套餐處於寬限期,此操作暫不可用,請續費後重試。
PICGO_CLOUD_ERROR_ACCOUNT_FROZEN: 帳戶已凍結,請續費以恢復使用。
PICGO_CLOUD_ERROR_IMPORT_DISABLED: 自動匯入已被關閉,請先在 PicGo Cloud 設定中啟用。
PICGO_CLOUD_ERROR_PLAN_INELIGIBLE: 目前方案不支援此功能,請升級後重試。
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_ACTIVE: 已達方案配額上限,升級到更高方案可獲得更多配額。
PICGO_CLOUD_ERROR_QUOTA_EXCEEDED_GRACE: 寬限期內配額已降級到免費版,請續費以恢復完整配額。
PICGO_CLOUD_ERROR_PLAN_REQUIRED: 此功能需要付費方案。
PICGO_CLOUD_USAGE_LOAD_FAILED: 用量資料載入失敗
PICGO_CLOUD_CONFIG_SYNC_LOAD_FAILED: 同步狀態載入失敗
PICGO_CLOUD_FREE_PLAN_BANNER: 你目前使用免費方案,升級後可解鎖更多配額與進階雲端功能。
PICGO_CLOUD_VIEW_PLANS: 查看方案
PICGO_CLOUD_CONFIG_SYNC_TITLE: 配置同步
PICGO_CLOUD_CONFIG_SYNC_CARD_DESC: 在不同裝置間安全同步你的配置。
PICGO_CLOUD_SYNC_NOW: 立即同步
PICGO_CLOUD_SYNC_QUOTA_LABEL: 同步配額
PICGO_CLOUD_SYNC_QUOTA_TIP: "目前方案保留最近 ${limit} 份歷史版本。即使顯示 ${limit} / ${limit},依然可以同步,每次同步後會自動清理超出範圍的舊版本。"
PICGO_CLOUD_LOGIN_PANEL_TITLE: 登入 PicGo Cloud
PICGO_CLOUD_LOGIN_PANEL_DESC: 登入即可使用 PicGo 官方圖床,並啟用雲端相關功能。
PICGO_CLOUD_LOGIN_FEATURES_TITLE: 雲端功能
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_TITLE: 官方圖床
PICGO_CLOUD_OFFICIAL_IMAGE_HOST_DESC: PicGo 官方圖床,內建相簿雲同步。
PICGO_CLOUD_PAID_PLAN_BADGE: 付費方案
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重啟嗎?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重啟後生效,是否立即重啟?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重啟
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: 稍後
INPUT_BOX_CONFIRM_MISMATCH: 兩次輸入不一致,請重新輸入。
PICGO_SPONSOR_TEXT: PicGo是開放原始碼的軟體,如果你喜歡它,對你有幫助,不妨請我喝杯咖啡?
ALIPAY: 支付寶
WECHATPAY: 微信支付
@@ -73,6 +198,9 @@ SETTINGS_CUSTOM_LINK_FORMAT: 自訂連結格式
SETTINGS_SET_PROXY_AND_MIRROR: 設定PROXY和鏡像地址
SETTINGS_SET_SERVER: 設定Server
SETTINGS_CHECK_UPDATE: 檢查更新
SETTINGS_UPDATE_CHECK_RESULT: 最新版本為 ${version}${action}
SETTINGS_UPDATE_CHECK_NO_UPDATE: 無需更新
SETTINGS_UPDATE_CHECK_CAN_UPDATE: 可以更新
SETTINGS_OPEN_UPDATE_HELPER: 打開更新助手
SETTINGS_OPEN:
SETTINGS_CLOSE:
@@ -184,21 +312,21 @@ SHORTCUT_DISABLE: 禁用
SHORTCUT_EDIT: 編輯
SHORTCUT_CHANGE_UPLOAD: 修改上傳快捷鍵
# gallery-page
GALLERY_URL_REWRITE_TITLE: 重寫選中圖片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
# album-page
ALBUM_URL_REWRITE_TITLE: 重寫選中圖片 URL
ALBUM_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
ALBUM_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
GALLERY_URL_REWRITE_APPLY_ONLY: 僅套用
GALLERY_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
ALBUM_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
ALBUM_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
ALBUM_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
ALBUM_URL_REWRITE_APPLY_ONLY: 僅套用
ALBUM_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
ALBUM_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
# tray-page
@@ -221,9 +349,12 @@ TIPS_INPUT_VALID_URL: 請輸入合法的URL
# plugins
PLUGIN_SEARCH_PLACEHOLDER: 搜尋npm上的PicGo插件,或者點擊上方按鈕查看優秀插件列表
PLUGIN_SEARCH_EXACT_MATCH: 插件名精確匹配
PLUGIN_INSTALL: 安裝
PLUGIN_INSTALLING: 安裝中
PLUGIN_INSTALLED: 已安裝
PLUGIN_DEPRECATED_BADGE: 已棄用
PLUGIN_DEPRECATED_TITLE: 此插件已被作者標記為棄用
PLUGIN_DOING_SOMETHING: 進行中
PLUGIN_LIST: 插件列表
PLUGIN_IMPORT_LOCAL: 導入本地插件
@@ -262,7 +393,7 @@ TOOLBOX: 工具箱
TOOLBOX_TITLE: 排查 PicGo 執行時問題
TOOLBOX_SUB_TITLE: 立即掃描以下項目,修復使用問題
TOOLBOX_CHECK_CONFIG_FILE_BROKEN: 檢查配置文件是否損壞
TOOLBOX_CHECK_GALLERY_FILE_BROKEN: 檢查相冊文件是否損壞
TOOLBOX_CHECK_ALBUM_FILE_BROKEN: 檢查相冊文件是否損壞
TOOLBOX_CHECK_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD: 檢查剪貼板圖片上傳是否存在問題
TOOLBOX_CHECK_PROBLEM_WITH_PROXY: 檢查代理設置是否正常
TOOLBOX_FIX_DONE_NEED_RELOAD: 修復完成,需要重啓生效,是否重啓
@@ -273,8 +404,8 @@ TOOLBOX_START_FIX: 開始修復
TOOLBOX_SUCCESS_TIPS: 恭喜你,沒有檢查出問題
TOOLBOX_CHECK_CONFIG_FILE_PATH_TIPS: 配置文件路徑是:${path}
TOOLBOX_CHECK_CONFIG_FILE_BROKEN_TIPS: 配置文件已損壞
TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS: 相冊文件路徑是:${path}
TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS: 相冊文件已損壞
TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS: 相冊文件路徑是:${path}
TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS: 相冊文件已損壞
TOOLBOX_CHECK_PROXY_SUCCESS_TIPS: 代理設置正常
TOOLBOX_CHECK_PROXY_NO_PROXY_TIPS: 無代理設置
TOOLBOX_CHECK_PROXY_PROXY_IS_NOT_CORRECT: 代理設置不正確
@@ -291,8 +422,8 @@ TIPS_SKIPPED_INVALID_URLS: 已跳過 ${n} 條非法 URL,請查看日誌了解
TIPS_TOO_MANY_URLS_CONFIRM: 你將一次上傳 ${n} 條 URL,可能會引起卡頓,建議分批上傳。是否繼續?
TIPS_NO_VALID_URLS: 未偵測到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 請安裝Node.js並重新啟動PicGo再繼續操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在試圖覆蓋相簿列表,是否繼續?
TIPS_PLUGIN_REMOVE_ALBUM_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
TIPS_PLUGIN_OVERWRITE_ALBUM: 有插件正在試圖覆蓋相簿列表,是否繼續?
TIPS_UPLOAD_NOT_PICTURES: 剪貼簿最新的一條記錄不是圖片
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_DEFAULT: PicGo 設定檔案已損壞,已經恢復為預設設定
TIPS_PICGO_CONFIG_FILE_BROKEN_WITH_BACKUP: PicGo 設定檔案已損壞,已經恢復為備份設定
@@ -304,39 +435,159 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: 自訂連結格式已經修改成功
TIPS_FIND_NEW_VERSION: 發現新版本${v},更新了很多功能,是否去下載最新的版本?
TIPS_DELETE_UPLOADER_CONFIG: 是否要刪除這個配置?
TIPS_COPY_UPLOADER_CONFIG: 是否要複製這個配置?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 配置名稱不能為空
TIPS_UPLOADER_CONFIG_NOT_FOUND: 未找到該配置
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 無法刪除最後一個配置
# privacy
PRIVACY: >
本軟體尊重並保護所有使用服務用戶的個人隱私權。為了給您提供更準確、更優質的服務,本軟體會按照本隱私權政策的規定使用和收集您的一些行為信息。您在同意本軟體服務使用協議之時,即視為您已經同意本隱私權政策全部內容。本隱私權政策屬於本軟體服務使用協議不可分割的一部分,如果不同意將無法使用。本協議會定期更新。
1.適用範圍
a)在您使用本軟體時,本軟體會記錄的您對本軟體的一些操作行為信息,包括但不限於您使用本軟體進行文件上傳的耗時、類型、數量等信息。
2.信息的使用
a)在獲得您的使用數據之後,本軟體會將其上傳至數據分析服務器,以便分析數據後,提供給您更好的服務。
3.信息披露
a)本軟體不會將您的信息披露給不受信任的第三方。
b)根據法律的有關規定,或者行政或司法機構的要求,向第三方或者行政、司法機構披露;
4.信息安全
a)本軟體不會收集您的個人信息、密鑰信息等隱私信息,所收集的信息僅僅作為改善軟體、優化體驗、了解軟體日活等用途。
PRIVACY: "使用前請閱讀並同意隱私政策 ${privacyUrl} 與服務條款 ${termsUrl},同意後方可使用。"
PRIVACY_TIPS: 請同意隱私協議,否則無法上傳。
QUIT: 退出
ALBUM_ALL_PHOTOS: 全部相片
ALBUM_COLLECTIONS: 合輯
ALBUM_TAGS: 標籤
ALBUM_MENU: 選單
ALBUM_OPEN_INSPECTOR: 開啟檢查器
ALBUM_INSPECTOR_TITLE: 相簿檢查器
ALBUM_INSPECTOR_DESCRIPTION: 檢視並編輯已選圖片。
ALBUM_CLEAR_SELECTION: 取消選取
ALBUM_SELECTED_COUNT: 已選 ${count} 項
ALBUM_GRID_VIEW: 網格檢視
ALBUM_LIST_VIEW: 清單檢視
ALBUM_PREVIEW: 全螢幕預覽
ALBUM_PREVIEW_EMPTY: 沒有可預覽的圖片
ALBUM_PREVIEW_PREV: 上一張
ALBUM_PREVIEW_NEXT: 下一張
ALBUM_PREVIEW_CLOSE: 關閉預覽
ALBUM_PREVIEW_COUNT: ${current} / ${total}
ALBUM_QUICK_ACTIONS: 快速操作
ALBUM_EXPORT: 下載
ALBUM_URL: URL
ALBUM_BATCH_REWRITE: 批次重寫
ALBUM_COLLECTION: 合輯
ALBUM_ADD_TAG: 新增標籤
ALBUM_TAG_SUGGESTIONS: 建議標籤
ALBUM_COLUMN_NAME: 名稱
ALBUM_COLUMN_PROVIDER: 圖床
ALBUM_COLUMN_SIZE: 大小
ALBUM_COLUMN_DATE: 日期
ALBUM_ADD: 新增
ALBUM_SOURCE_LOCAL: 本機
ALBUM_SOURCE_CLOUD: 雲端
ALBUM_CLOUD_LOAD_FAILED: 載入雲端相簿失敗
ALBUM_CLOUD_LOGIN_REQUIRED_TITLE: 需要登入
ALBUM_CLOUD_LOGIN_REQUIRED_DESC: 登入 PicGo Cloud 付費帳戶以使用雲端相簿
ALBUM_CLOUD_LOGIN_BUTTON: 前往登入
ALBUM_CLOUD_UPGRADE_TITLE: 需要升級
ALBUM_CLOUD_UPGRADE_DESC: 雲端相簿需要付費方案
ALBUM_CLOUD_UPGRADE_BUTTON: 升級方案
ALBUM_CLOUD_FEATURES_TITLE: 為什麼使用雲端相簿?
ALBUM_CLOUD_FEATURE_SYNC: 多裝置存取上傳歷史記錄
ALBUM_CLOUD_FEATURE_AUTO_IMPORT: 上傳後自動匯入相簿記錄到雲端(需開啟該功能)
ALBUM_CLOUD_FEATURE_SECURE: 一鍵匯入本機歷史到雲端
ALBUM_CLOUD_IMPORT_GUIDE_TITLE: 匯入本機記錄
ALBUM_CLOUD_IMPORT_GUIDE_DESC: 將本機上傳記錄匯入到雲端相簿。僅匯入記錄,圖片檔案本身不會被上傳。
ALBUM_CLOUD_IMPORT_GUIDE_BUTTON: 開始匯入
ALBUM_CLOUD_EMPTY_TITLE: 暫無內容
ALBUM_CLOUD_EMPTY_DESC: 雲端相簿為空,上傳圖片即可開始使用。
ALBUM_CLOUD_IMPORTING: 正在匯入到雲端相簿...
ALBUM_CLOUD_IMPORT_SUCCESS: 成功匯入 ${num} 條記錄到雲端相簿
ALBUM_CLOUD_IMPORT_FAILED: 匯入雲端相簿失敗
ALBUM_CLOUD_REFRESH: 重新整理
ALBUM_CLOUD_IMPORT_CONFIRM_TITLE: 開啟自動匯入
ALBUM_CLOUD_IMPORT_CONFIRM_DESC: 此操作將開啟自動匯入功能,後續上傳的圖片記錄會自動同步到雲端相簿。同時會將現有的本地記錄一併匯入。
ALBUM_CLOUD_DELETE_FAILED: 從雲端相簿刪除失敗
ALBUM_INSPECTOR_DETAILS_TITLE: 詳情
ALBUM_INSPECTOR_FILE_NAME: 檔案名稱
ALBUM_INSPECTOR_UPLOADER: 上傳器
ALBUM_INSPECTOR_CONTENT_TYPE: 內容類型
ALBUM_INSPECTOR_CREATED_AT: 建立時間
ALBUM_INSPECTOR_UPDATED_AT: 更新時間
ALBUM_INSPECTOR_FILE_SIZE: 檔案大小
ALBUM_CLOUD_IMPORT_STATUS_TITLE: 雲端相簿
ALBUM_CLOUD_IMPORTED: 已匯入雲端
ALBUM_CLOUD_IMPORTED_TOOLTIP: 這是本機標記,不會與雲端同步。如果雲端記錄已被刪除,此狀態不會更新。你可以重新匯入。
ALBUM_CLOUD_REIMPORT: 重新匯入
ALBUM_CLOUD_NATIVE: 透過 PicGo Cloud 上傳
ALBUM_CLOUD_IMPORT_BUTTON: 匯入到雲端相簿
ALBUM_CLOUD_IMPORT_BUTTON_COUNT: 匯入 ${num} 條記錄到雲端相簿
ALBUM_CLOUD_ALL_IMPORTED: 已全部在雲端相簿中
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_TITLE: 需要開啟自動匯入
ALBUM_CLOUD_AUTO_IMPORT_REQUIRED_DESC: 匯入到雲端相簿前需要先開啟自動匯入功能。是否立即開啟並繼續匯入?
ALBUM_CLOUD_AUTO_IMPORT_ENABLE_AND_IMPORT: 開啟並匯入
ALBUM_CLOUD_IMPORT_SINGLE_SUCCESS: 已成功匯入到雲端相簿
PICGO_CLOUD_AUTO_IMPORT_LABEL: 自動匯入雲端相簿
PICGO_CLOUD_AUTO_IMPORT_DESC: 自動將其他圖床的上傳記錄匯入雲端相簿(PicGo Cloud 預設已寫入)
SIDEBAR_PLUGINS: 插件
SIDEBAR_SYSTEM: 系統
SIDEBAR_NOTIFICATIONS: 通知
SIDEBAR_EXPAND: 點擊展開
SIDEBAR_COLLAPSE: 收合側邊欄
HISTORY_PANEL_TITLE: 歷史記錄
HISTORY_PANEL_FILTER_PLACEHOLDER: 篩選...
HISTORY_PANEL_TODAY: 今天
HISTORY_PANEL_YESTERDAY: 昨天
DASHBOARD_NO_VISIBLE_PROVIDERS: 沒有可見圖床
DASHBOARD_NO_VISIBLE_PROVIDERS_DESCRIPTION: 目前所有圖床都被隱藏了,請前往設定調整可見圖床。
DASHBOARD_OPEN_SETTINGS: 打開設定
DASHBOARD_DROP_IMAGES_HERE: 將檔案拖曳到此處
DASHBOARD_OR:
DASHBOARD_CLICK_TO_UPLOAD: 點擊上傳
DASHBOARD_PASTE_FROM_CLIPBOARD: 從剪貼簿貼上
DASHBOARD_PASTE_FROM_URL: 從 URL 貼上
DASHBOARD_CLIPBOARD: 剪貼簿
FIELD_IS_REQUIRED: ${field} 為必填項
CONFIG_RENAME: 配置改名
SETTINGS_SECTION_GENERAL: 一般
SETTINGS_SECTION_APPEARANCE: 外觀
SETTINGS_SECTION_UPLOAD_WORKFLOW: 上傳流程
SETTINGS_SECTION_NETWORK: 網路
SETTINGS_SECTION_ADVANCED: 進階
SETTINGS_SECTION_ABOUT: 關於
SETTINGS_NO_RESULTS_TITLE: 沒有符合的設定項
SETTINGS_NO_RESULTS_DESCRIPTION: 請嘗試其他關鍵字,或清空搜尋條件。
SETTINGS_OPEN_SHORTCUTS: 快捷鍵
SETTINGS_LINK_WEBSITE: 官網
SETTINGS_LINK_GITHUB: GitHub
SETTINGS_LINK_DOCS: 文件
SETTINGS_LINK_PRIVACY: 隱私政策
SETTINGS_LINK_TERMS: 服務條款
SETTINGS_APPEARANCE_MODE: 主題模式
SETTINGS_APPEARANCE_MODE_LIGHT: 淺色
SETTINGS_APPEARANCE_MODE_DARK: 深色
SETTINGS_APPEARANCE_MODE_AUTO: 跟隨系統
COMMON_FOR_EXAMPLE: 例如:${value}
SETTINGS_SET_DEFAULT_CONFIG: 設為預設配置
PROVIDER_DRAFT_CONFIG: 草稿
PROVIDER_ACTIVE_UPLOADER_LABEL: 目前
PROVIDER_ACTIVE_UPLOADER_TOOLTIP: ${uploaderName} 是目前使用的上傳器
PROVIDER_DEFAULT_CONFIG_LABEL: 預設
PROVIDER_DEFAULT_CONFIG_TOOLTIP: 當 ${uploaderName} 處於目前啟用狀態時使用。
PROVIDER_SIDEBAR_EMPTY: 找不到圖床或配置。
PROVIDER_UPLOADER_ACTIONS: ${uploaderName} 操作
PROVIDER_SIDEBAR_COLLAPSE: 收合
PROVIDER_SIDEBAR_EXPAND: 展開
PROVIDER_CONFIG_ACTIONS: 配置操作
PROVIDER_CREATE_CONFIG: 新增配置
PROVIDER_CREATE_CONFIG_DISABLED_EMPTY_SCHEMA: 該上傳器沒有配置項,無需新增配置
PROVIDER_INSTALL_MORE_UPLOADERS: 安裝更多圖床
PROVIDER_NO_UPLOADER_SELECTED: 尚未選擇圖床。
PROVIDER_NO_CONFIG_YET: 尚無配置
PROVIDER_NO_CONFIG_DESCRIPTION: 為 ${uploaderName} 建立一個新配置後即可開始編輯配置項。
PROVIDER_CONFIGURATION: 配置項
PROVIDER_UPDATED_AT_LABEL: 更新時間
PROVIDER_DELETE_CONFIG_HINT: 此操作會永久刪除目前配置。
UPLOADER_SWITCHER_SELECT_PROVIDER: 選擇圖床
UPLOADER_SWITCHER_CONFIG: 配置
UPLOADER_SWITCHER_NO_CONFIG: 暫無配置
ALBUM_URL_REWRITE_DESCRIPTION: 批量重寫所選圖片的 URL。
ALBUM_URL_REWRITE_CHANGED: 已修改
ALBUM_URL_REWRITE_UNCHANGED: 未修改
TRAY_ALREADY_UPLOAD_EMPTY: 暫無已上傳圖片
PLUGIN_EMPTY: 尚未找到任何插件
PLUGIN_EMPTY_DESCRIPTION: 可以先在上方搜尋並安裝一些插件
PLUGIN_DETAIL: 詳情
PLUGIN_NO_CONFIG_SCHEMA: 該插件未暴露 config 配置項。
PLUGIN_NO_TRANSFORMER_SCHEMA: 該插件未暴露 transformer 配置項。
PLUGIN_NO_README: 該插件暫無 README 內容。
NO_OPTIONS_FOUND: 沒有可選項
+3 -3
View File
@@ -16,15 +16,15 @@ const macos = [
const linux = {
AppImage: [
{ label: '64 bit', arch: '-x64', ext: '.AppImage' },
{ label: '64 bit', arch: '-amd64', ext: '.AppImage' },
{ label: 'ARM64', arch: '-arm64', ext: '.AppImage' }
],
Deb: [
{ label: '64 bit', arch: '-x64', ext: '.deb' },
{ label: '64 bit', arch: '-amd64', ext: '.deb' },
{ label: 'ARM64', arch: '-arm64', ext: '.deb' }
],
Snap: [
{ label: '64 bit', arch: '-x64', ext: '.snap' }
{ label: '64 bit', arch: '-amd64', ext: '.snap' }
]
}
-22
View File
@@ -1,22 +0,0 @@
const yaml = require('js-yaml')
const path = require('path')
const fs = require('fs')
const languageFileName = 'zh-CN.yml' // use zh-CN for type is OK
const i18nFolder = path.join(__dirname, '../public/i18n')
const typeFolder = path.join(__dirname, '../src/universal/types')
const languageFile = path.join(i18nFolder, languageFileName)
const langFile = fs.readFileSync(languageFile, 'utf8')
const obj = yaml.load(langFile)
const keys = Object.keys(obj)
const types =
`interface ILocales {
${keys.map(key => `${key}: string`).join('\n ')}
}
type ILocalesKey = keyof ILocales
`
fs.writeFileSync(path.join(typeFolder, 'i18n.d.ts'), types)
+197
View File
@@ -0,0 +1,197 @@
/**
* Merge artifacts from different platforms and architectures
* Also merge latest*.yml files for electron-updater
*/
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const ARTIFACTS_DIR = path.join(__dirname, '../artifacts')
const DIST_DIR = path.join(__dirname, '../dist')
// yml 文件分组规则
const YML_MERGE_RULES = {
// macOS: 合并 x64 和 arm64 的 latest-mac.yml
'latest-mac.yml': ['latest-mac.yml'],
// Windows: 合并所有架构的 latest.yml
'latest.yml': ['latest.yml'],
// Linux x64: latest-linux.yml
'latest-linux.yml': ['latest-linux.yml'],
// Linux arm64: latest-linux-arm64.yml
'latest-linux-arm64.yml': ['latest-linux-arm64.yml']
}
/**
* 递归查找指定文件名的所有文件
*/
function findFiles(dir, filename) {
const results = []
if (!fs.existsSync(dir)) {
return results
}
const items = fs.readdirSync(dir)
for (const item of items) {
const fullPath = path.join(dir, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
results.push(...findFiles(fullPath, filename))
} else if (item === filename) {
results.push(fullPath)
}
}
return results
}
/**
* 合并多个 yml 文件
*/
function mergeYmlFiles(files) {
if (files.length === 0) return null
if (files.length === 1) {
return yaml.load(fs.readFileSync(files[0], 'utf8'))
}
const contents = files.map(f => yaml.load(fs.readFileSync(f, 'utf8')))
// 以第一个为基准,合并 files 数组
const merged = {
version: contents[0].version,
files: [],
releaseDate: contents[0].releaseDate
}
for (const content of contents) {
if (content.files && Array.isArray(content.files)) {
merged.files.push(...content.files)
}
}
// 去重(根据 sha512
const seen = new Set()
merged.files = merged.files.filter(file => {
const key = file.sha512
if (seen.has(key)) return false
seen.add(key)
return true
})
// 设置 path/sha512/size 为第一个文件(electron-updater 兼容性)
if (merged.files.length > 0) {
merged.path = merged.files[0].url
merged.sha512 = merged.files[0].sha512
merged.size = merged.files[0].size
}
return merged
}
/**
* 复制所有构建产物到 dist 目录
*/
function copyArtifacts() {
console.log('📁 Copying all artifacts to dist...\n')
if (!fs.existsSync(ARTIFACTS_DIR)) {
console.log('⚠️ No artifacts directory found')
return
}
const platformDirs = fs.readdirSync(ARTIFACTS_DIR)
for (const platformDir of platformDirs) {
const platformPath = path.join(ARTIFACTS_DIR, platformDir)
const stat = fs.statSync(platformPath)
if (!stat.isDirectory()) continue
console.log(`📦 Processing ${platformDir}...`)
const files = fs.readdirSync(platformPath)
for (const file of files) {
const srcPath = path.join(platformPath, file)
const destPath = path.join(DIST_DIR, file)
const fileStat = fs.statSync(srcPath)
// 跳过目录和 yml 文件(yml 文件会单独处理合并)
if (fileStat.isDirectory()) continue
if (file.endsWith('.yml')) continue
// 如果目标文件已存在且大小相同,跳过
if (fs.existsSync(destPath)) {
const destStat = fs.statSync(destPath)
if (destStat.size === fileStat.size) {
console.log(` ⏭️ Skipped (exists): ${file}`)
continue
}
}
fs.copyFileSync(srcPath, destPath)
console.log(` ✅ Copied: ${file}`)
}
}
}
/**
* 合并 yml 文件
*/
function mergeYmlFilesFromArtifacts() {
console.log('\n🔀 Merging yml files...\n')
for (const [outputName, sourceNames] of Object.entries(YML_MERGE_RULES)) {
const allFiles = []
for (const sourceName of sourceNames) {
const files = findFiles(ARTIFACTS_DIR, sourceName)
allFiles.push(...files)
}
if (allFiles.length === 0) {
console.log(`⏭️ No ${outputName} found, skipping...`)
continue
}
console.log(`📄 Found ${allFiles.length} ${outputName} file(s):`)
allFiles.forEach(f => console.log(` - ${path.relative(ARTIFACTS_DIR, f)}`))
const merged = mergeYmlFiles(allFiles)
if (merged) {
const outputPath = path.join(DIST_DIR, outputName)
fs.writeFileSync(outputPath, yaml.dump(merged, { lineWidth: -1 }))
console.log(`✅ Merged -> ${outputName}`)
if (merged.files) {
console.log(` Files: ${merged.files.map(f => f.url).join(', ')}`)
}
console.log('')
}
}
}
async function main() {
console.log('🚀 Starting artifact merge process...\n')
// 确保 dist 目录存在
if (!fs.existsSync(DIST_DIR)) {
fs.mkdirSync(DIST_DIR, { recursive: true })
}
// 1. 复制所有构建产物
copyArtifacts()
// 2. 合并 yml 文件
mergeYmlFilesFromArtifacts()
console.log('🎉 Artifact merge completed!')
}
main().catch(err => {
console.error('❌ Error:', err)
process.exit(1)
})
+79
View File
@@ -0,0 +1,79 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const yaml = require('js-yaml')
const distDir = path.join(__dirname, '../dist')
const yamlPath = path.join(distDir, 'latest.yml')
if (!fs.existsSync(yamlPath)) {
console.log('⚠️ latest.yml not found in dist/. Skipping update.')
process.exit(0)
}
console.log(`Reading ${yamlPath}...`)
const yamlContent = fs.readFileSync(yamlPath, 'utf8')
let doc
try {
doc = yaml.load(yamlContent)
} catch (e) {
console.error('❌ Failed to parse latest.yml:', e)
process.exit(1)
}
// Get all .exe files in dist directory
const files = fs.readdirSync(distDir).filter(f => f.endsWith('.exe'))
if (files.length === 0) {
console.log('⚠️ No .exe files found in dist/.')
process.exit(0)
}
let updated = false
files.forEach(file => {
const filePath = path.join(distDir, file)
// 1. Calculate new Hash and Size
const buffer = fs.readFileSync(filePath)
const hash = crypto.createHash('sha512').update(buffer).digest('base64')
const size = fs.statSync(filePath).size
console.log(`Processing ${file}:`)
console.log(` -> New Hash: ${hash}`)
console.log(` -> New Size: ${size}`)
// 2. Update entries in 'files' list
if (Array.isArray(doc.files)) {
const fileEntry = doc.files.find(f => f.url === file)
if (fileEntry) {
fileEntry.sha512 = hash
fileEntry.size = size
updated = true
console.log(' -> Updated file entry in yaml object')
}
}
// 3. Update root path entry (if exists)
// electron-builder's latest.yml usually has root path, sha512, size
// corresponding to the main file of current build (usually x64 or current arch)
if (doc.path === file) {
doc.sha512 = hash
doc.size = size
updated = true
console.log(' -> Updated root path entry in yaml object')
}
})
if (updated) {
// 4. Dump back to file
// lineWidth: -1 prevents long strings from wrapping, keeping it clean
const newYamlContent = yaml.dump(doc, { lineWidth: -1 })
fs.writeFileSync(yamlPath, newYamlContent, 'utf8')
console.log('✅ latest.yml updated successfully.')
console.log('New yaml content:')
console.log(newYamlContent)
} else {
console.log('⚠️ No matching entries found in latest.yml to update.')
}
+118 -147
View File
@@ -2,8 +2,6 @@
// upload version file to cos
require('dotenv').config()
// const crypto = require('crypto')
// const axios = require('axios').default
const fs = require('fs')
const pkg = require('../package.json')
const configList = require('./config')
@@ -12,15 +10,13 @@ const path = require('path')
const distPath = path.join(__dirname, '../dist')
const S3Client = require('@aws-sdk/client-s3').S3Client
const Upload = require('@aws-sdk/lib-storage').Upload
// const BUCKET = 'picgo-1251750343'
// const COS_SECRET_ID = process.env.PICGO_ENV_COS_SECRET_ID
// const COS_SECRET_KEY = process.env.PICGO_ENV_COS_SECRET_KEY
const uploadToDev = process.argv.includes('--dev')
const S3_BUCKET = 'release'
const S3_LEGACY_BUCKET = 'picgo'
// const AREA = 'ap-chengdu'
const VERSION = pkg.version
const FILE_PATH = `${VERSION}/`
const DEV_DIST_PREFIX = 'dev/'
const FILE_PATH = uploadToDev ? `${DEV_DIST_PREFIX}${VERSION}/` : `${VERSION}/`
const S3_SECRET_ID = process.env.PICGO_ENV_S3_SECRET_ID
const S3_SECRET_KEY = process.env.PICGO_ENV_S3_SECRET_KEY
const S3_ACCOUNT_ID = process.env.PICGO_ENV_S3_ACCOUNT_ID
@@ -49,155 +45,130 @@ const S3LegacyOptions = {
region: 'auto'
}
// https://cloud.tencent.com/document/product/436/7778#signature
// /**
// * @param {string} fileName
// * @returns
// */
// const generateSignature = (fileName, folder = FILE_PATH) => {
// const secretKey = COS_SECRET_ID
// // const area = AREA
// const bucket = BUCKET
// const path = folder
// const today = Math.floor(new Date().getTime() / 1000)
// const tomorrow = today + 86400
// const signTime = `${today};${tomorrow}`
// const signKey = crypto.createHmac('sha1', secretKey).update(signTime).digest('hex')
// const httpString = `put\n/${path}${fileName}\n\nhost=${bucket}.cos.accelerate.myqcloud.com\n`
// const sha1edHttpString = crypto.createHash('sha1').update(httpString).digest('hex')
// const stringToSign = `sha1\n${signTime}\n${sha1edHttpString}\n`
// const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
// return {
// signature,
// signTime
// }
// }
// /**
// *
// * @param {string} fileName
// * @param {Buffer} fileBuffer
// * @param {{ signature: string, signTime: string }} signature
// * @returns
// */
// const getReqOptions = (fileName, fileBuffer, signature, folder = FILE_PATH) => {
// return {
// method: 'PUT',
// url: `http://${BUCKET}.cos.accelerate.myqcloud.com/${encodeURI(folder)}${encodeURI(fileName)}`,
// headers: {
// Host: `${BUCKET}.cos.accelerate.myqcloud.com`,
// Authorization: `q-sign-algorithm=sha1&q-ak=${COS_SECRET_KEY}&q-sign-time=${signature.signTime}&q-key-time=${signature.signTime}&q-header-list=host&q-url-param-list=&q-signature=${signature.signature}`,
// contentType: mime.lookup(fileName),
// useAgent: `PicGo;${pkg.version};null;null`
// },
// maxContentLength: Infinity,
// maxBodyLength: Infinity,
// data: fileBuffer,
// resolveWithFullResponse: true
// }
// }
/**
* 检查是否使用 --all 参数(上传所有平台)
*/
function shouldUploadAll() {
return process.argv.includes('--all')
}
/**
* a backup for version file
* 获取要上传的配置列表
*/
// const uploadVersionFile = async () => {
// try {
// const platform = process.platform
// if (configList[platform]) {
// let versionFileHasUploaded = false
// for (const [, config] of configList[platform].entries()) {
// const versionFilePath = path.join(distPath, config['version-file'])
// let versionFileName = config['version-file']
// if (VERSION.toLocaleLowerCase().includes('beta')) {
// versionFileName = versionFileName.replace('.yml', '.beta.yml')
// }
// // upload version file
// if (!versionFileHasUploaded) {
// const signature = generateSignature(versionFileName, '')
// const reqOptions = getReqOptions(versionFileName, fs.readFileSync(versionFilePath), signature, '')
// console.log('[PicGo Version File] Uploading...', versionFileName)
// await axios.request(reqOptions)
// versionFileHasUploaded = true
// }
// }
// } else {
// console.warn('platform not supported!', platform)
// }
// } catch (e) {
// console.error(e)
// }
// }
function getUploadConfigs() {
if (shouldUploadAll()) {
// 合并所有平台的配置
return [
...configList.darwin,
...configList.win32,
...configList.linux
]
}
// 原有逻辑:根据当前平台
const platform = process.platform
return configList[platform] || []
}
/**
* 上传单个文件到 S3
*/
async function uploadFileToS3(client, bucket, key, filePath, contentType = 'application/octet-stream') {
const upload = new Upload({
client,
params: {
Bucket: bucket,
Key: key,
Body: fs.createReadStream(filePath),
ContentType: contentType
}
})
upload.on('httpUploadProgress', progress => {
const percent = progress.total ? Math.round((progress.loaded / progress.total) * 100) : 0
process.stdout.write(`\r Progress: ${progress.loaded}/${progress.total || '?'} (${percent}%)`)
})
await upload.done()
console.log('') // 换行
}
const uploadDist = async () => {
try {
const platform = process.platform
if (configList[platform]) {
const uploadedVersionFiles = new Set()
for (const [index, config] of configList[platform].entries()) {
const fileName = `${config.appNameWithPrefix}-${VERSION}-${config.arch}.${config.ext}`
const filePath = path.join(distPath, fileName)
const versionFilePath = path.join(distPath, config['version-file'])
let versionFileName = config['version-file']
if (VERSION.toLocaleLowerCase().includes('beta')) {
versionFileName = versionFileName.replace('.yml', '.beta.yml')
}
console.log('[PicGo Dist] Preparing to upload', fileName)
const client = new S3Client(S3Options)
if (fs.existsSync(filePath)) {
const uploadDistToS3 = new Upload({
client,
params: {
Bucket: S3_BUCKET,
Key: `${FILE_PATH}${fileName}`,
Body: fs.createReadStream(filePath),
ContentType: 'application/octet-stream'
}
})
// upload dist file
console.log('[PicGo Dist] Uploading...', fileName, `${index + 1}/${configList[platform].length}`)
uploadDistToS3.on('httpUploadProgress', progress => {
console.log(`[PicGo Dist] Uploading... ${progress.loaded}/${progress.total}`)
})
await uploadDistToS3.done()
} else {
console.warn('[PicGo Dist] File not found:', fileName)
}
const configs = getUploadConfigs()
// upload version file
if (!uploadedVersionFiles.has(versionFileName) && fs.existsSync(versionFilePath)) {
const uploadVersionFileToS3 = new Upload({
client,
params: {
Bucket: S3_BUCKET,
Key: `${versionFileName}`,
Body: fs.createReadStream(versionFilePath),
ContentType: mime.lookup(versionFileName)
}
})
// upload to legacy bucket as well
// will be deprecated in 2.5.0
const legacyClient = new S3Client(S3LegacyOptions)
const uploadVersionFileToLegacyS3 = new Upload({
client: legacyClient,
params: {
Bucket: S3_LEGACY_BUCKET,
Key: `${versionFileName}`,
Body: fs.createReadStream(versionFilePath),
ContentType: mime.lookup(versionFileName)
}
})
console.log('[PicGo Version File] Uploading...', versionFileName)
await uploadVersionFileToS3.done()
await uploadVersionFileToLegacyS3.done()
uploadedVersionFiles.add(versionFileName)
console.log('[PicGo Version File] Upload successfully')
}
}
} else {
console.warn('platform not supported!', platform)
if (configs.length === 0) {
console.warn('[PicGo] No upload config found!')
return
}
console.log(`[PicGo] Upload mode: ${shouldUploadAll() ? 'ALL PLATFORMS' : process.platform}`)
console.log(`[PicGo] Version: ${VERSION}`)
console.log(`[PicGo] Total files to upload: ${configs.length}\n`)
const uploadedVersionFiles = new Set()
const client = new S3Client(S3Options)
const legacyClient = new S3Client(S3LegacyOptions)
for (const [index, config] of configs.entries()) {
const fileName = `${config.appNameWithPrefix}-${VERSION}-${config.arch}.${config.ext}`
const filePath = path.join(distPath, fileName)
let versionFileName = config['version-file']
console.log(`[${index + 1}/${configs.length}] Processing ${fileName}`)
// 上传构建产物
if (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 {
console.warn(` ⚠️ File not found: ${fileName}`)
}
let versionFilePath = path.join(distPath, versionFileName)
// Beta 版本使用不同的 yml 文件名
if (VERSION.toLowerCase().includes('beta') && fs.existsSync(versionFilePath)) {
versionFileName = versionFileName.replace('.yml', '.beta.yml')
const betaVersionFilePath = path.join(distPath, versionFileName)
// change to beta version file path
fs.renameSync(versionFilePath, betaVersionFilePath)
versionFilePath = betaVersionFilePath
}
// 上传版本文件(每个 yml 只上传一次)
if (!uploadedVersionFiles.has(versionFileName) && fs.existsSync(versionFilePath)) {
console.log(` Uploading version file: ${versionFileName}`)
const versionFileNameFinal = uploadToDev ? `${DEV_DIST_PREFIX}${versionFileName}` : versionFileName
// 上传到主 bucket
await uploadFileToS3(
client,
S3_BUCKET,
versionFileNameFinal,
versionFilePath,
mime.lookup(versionFileName) || 'text/yaml'
)
// 上传到 legacy bucket
await uploadFileToS3(
legacyClient,
S3_LEGACY_BUCKET,
versionFileNameFinal,
versionFilePath,
mime.lookup(versionFileName) || 'text/yaml'
)
uploadedVersionFiles.add(versionFileName)
console.log(` ✅ Version file uploaded: ${versionFileName}`)
}
console.log('')
}
console.log('[PicGo] 🎉 All uploads completed!')
} catch (e) {
console.error(e)
console.error('[PicGo] ❌ Upload error:', e)
process.exit(1)
}
}
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs'
import path from 'node:path'
import { parse } from 'yaml'
import type { Plugin } from 'vite'
const I18N_SOURCE_DIR = 'public/i18n'
const SOURCE_RELATIVE_PATH = `${I18N_SOURCE_DIR}/en.yml`
const UNIVERSAL_OUTPUT_RELATIVE_PATH = 'src/universal/types/i18n.d.ts'
const RENDERER_OUTPUT_RELATIVE_PATH = 'src/renderer/i18n/i18next.d.ts'
function formatKey (key: string) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
}
function readTranslationKeys (sourcePath: string) {
if (!fs.existsSync(sourcePath)) {
return []
}
const raw = fs.readFileSync(sourcePath, 'utf8')
const data = parse(raw)
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return []
}
return Object.keys(data as Record<string, unknown>)
}
function buildUniversalTypeContent (keys: string[]) {
const uniqueKeys = Array.from(new Set(keys))
const lines = uniqueKeys.map((key) => ` ${formatKey(key)}: string`)
return `// This file is auto-generated by vite-plugin-i18n-types. Do not edit.
interface ILocales {
${lines.join('\n')}
}
type ILocalesKey = keyof ILocales
`
}
function buildRendererTypeContent () {
return `// This file is auto-generated by vite-plugin-i18n-types. Do not edit.
import "i18next"
declare module "i18next" {
interface CustomTypeOptions {
resources: {
translation: ILocales
}
}
}
`
}
function writeFileIfChanged (outputPath: string, nextContent: string) {
const previousContent = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, 'utf8')
: ''
if (previousContent === nextContent) {
return
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, nextContent, 'utf8')
}
function writeTypesFiles (sourcePath: string, universalOutputPath: string, rendererOutputPath: string) {
const keys = readTranslationKeys(sourcePath)
writeFileIfChanged(universalOutputPath, buildUniversalTypeContent(keys))
writeFileIfChanged(rendererOutputPath, buildRendererTypeContent())
}
export function i18nTypesPlugin (): Plugin {
let sourcePath = ''
let universalOutputPath = ''
let rendererOutputPath = ''
let projectRoot = ''
const generate = () => {
try {
writeTypesFiles(sourcePath, universalOutputPath, rendererOutputPath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[vite-plugin-i18n-types] ${message}`)
}
}
return {
name: 'vite-plugin-i18n-types',
enforce: 'pre',
configResolved (config) {
projectRoot = path.resolve(config.root, '../..')
sourcePath = path.resolve(projectRoot, SOURCE_RELATIVE_PATH)
universalOutputPath = path.resolve(projectRoot, UNIVERSAL_OUTPUT_RELATIVE_PATH)
rendererOutputPath = path.resolve(projectRoot, RENDERER_OUTPUT_RELATIVE_PATH)
},
buildStart () {
generate()
},
configureServer (server) {
server.watcher.add(path.resolve(projectRoot, I18N_SOURCE_DIR))
},
handleHotUpdate ({ file }) {
const normalizedFile = path.resolve(file)
const normalizedSourceDir = path.resolve(path.dirname(sourcePath))
if (normalizedFile.startsWith(normalizedSourceDir)) {
generate()
}
}
}
}
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import os from 'node:os'
import path from 'node:path'
import fs from 'fs-extra'
import { IRPCActionType } from '~/universal/types/enum'
import { IPicGoCloudConfigSyncToastType } from '#/types/cloudConfigSync'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: vi.fn(() => os.tmpdir()),
getAppPath: vi.fn(() => process.cwd())
}
}))
type OnAskEncryptionSwitch = (context: { from: string; to: string }) => Promise<boolean>
type SyncImplementation = (askSwitch?: OnAskEncryptionSwitch) => Promise<{ status: string; message?: string }>
const showMessageBoxMock = vi.fn()
const showInputBoxMock = vi.fn()
const getUserInfoMock = vi.fn()
const getConfigMock = vi.fn()
const saveConfigMock = vi.fn()
const i18nTranslateMock = vi.fn((key: string) => key)
let baseDir = ''
let syncImplementation: SyncImplementation | null = null
const createInvokeEvent = (): IpcMainInvokeEvent => {
const event = {
sender: {
send: vi.fn()
}
}
return event as unknown as IpcMainInvokeEvent
}
vi.mock('@core/picgo', () => {
return {
default: {
get baseDir () {
return baseDir
},
getConfig: getConfigMock,
saveConfig: saveConfigMock,
cloud: {
getUserInfo: getUserInfoMock,
login: vi.fn(),
logout: vi.fn(),
disposeLoginFlow: vi.fn()
},
i18n: {
translate: i18nTranslateMock
}
}
}
})
vi.mock('apis/gui', () => {
return {
default: {
getInstance: () => ({
showInputBox: showInputBoxMock,
showMessageBox: showMessageBoxMock
})
}
}
})
vi.mock('apis/core/picgo/logger', () => {
return {
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}
})
vi.mock('picgo', () => {
const SyncStatus = {
SUCCESS: 'success',
CONFLICT: 'conflict',
FAILED: 'failed'
}
const EncryptionMethod = {
AUTO: 'auto',
SSE: 'sse',
E2EE: 'e2ee'
}
const E2EAskPinReason = {
SETUP: 'setup',
DECRYPT: 'decrypt',
RETRY: 'retry'
}
const ConflictType = {
CONFLICT: 'conflict'
}
class ConfigSyncManager {
private readonly onAskEncryptionSwitch?: OnAskEncryptionSwitch
constructor (_ctx: unknown, options: { onAskEncryptionSwitch?: OnAskEncryptionSwitch } = {}) {
this.onAskEncryptionSwitch = options.onAskEncryptionSwitch
}
async sync (): Promise<{ status: string; message?: string }> {
if (syncImplementation) {
return syncImplementation(this.onAskEncryptionSwitch)
}
return { status: SyncStatus.SUCCESS }
}
}
return {
ConfigSyncManager,
ConflictType,
E2EAskPinReason,
EncryptionMethod,
SyncStatus
}
})
describe('config sync encryption switch confirmation (main)', () => {
beforeEach(async () => {
vi.clearAllMocks()
syncImplementation = null
getConfigMock.mockReturnValue(undefined)
getUserInfoMock.mockResolvedValue({ user: 'tester' })
i18nTranslateMock.mockImplementation((key: string) => key)
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picgo-gui-config-sync-'))
})
afterEach(async () => {
if (baseDir) {
await fs.remove(baseDir)
}
})
it('prompts for confirmation and proceeds on confirm', async () => {
showMessageBoxMock.mockResolvedValue({ result: 0, checkboxChecked: false })
syncImplementation = async (askSwitch) => {
if (askSwitch) {
const confirmed = await askSwitch({ from: 'e2ee', to: 'sse' })
if (!confirmed) {
return { status: 'failed', message: i18nTranslateMock('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED') }
}
}
return { status: 'success', message: 'ok' }
}
const { cloudRouter } = await import('../../main/events/rpc/routes/cloud')
const handler = cloudRouter.routes().get(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START)
const res = await handler?.([], createInvokeEvent())
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(showMessageBoxMock).toHaveBeenCalledWith({
title: 'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE',
message: 'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY',
type: 'warning',
buttons: [
'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM',
'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL'
]
})
expect(res?.success).toBe(true)
expect(res?.data.toastType).toBe(IPicGoCloudConfigSyncToastType.SUCCESS)
expect(res?.data.message).toBe('PICGO_CLOUD_CONFIG_SYNC_SUCCESS')
})
it('maps encryption-switch cancel to warning', async () => {
showMessageBoxMock.mockResolvedValue({ result: 1, checkboxChecked: false })
syncImplementation = async (askSwitch) => {
if (askSwitch) {
const confirmed = await askSwitch({ from: 'sse', to: 'e2ee' })
if (!confirmed) {
return { status: 'failed', message: i18nTranslateMock('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED') }
}
}
return { status: 'success', message: 'ok' }
}
const { cloudRouter } = await import('../../main/events/rpc/routes/cloud')
const handler = cloudRouter.routes().get(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START)
const res = await handler?.([], createInvokeEvent())
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(res?.success).toBe(true)
expect(res?.data.toastType).toBe(IPicGoCloudConfigSyncToastType.WARNING)
expect(res?.data.message).toBe('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RELEASE_URL, RELEASE_URL_BACKUP } from '#/utils/static'
import { getLatestVersion } from '~/main/utils/getLatestVersion'
const { axiosGetMock } = vi.hoisted(() => {
return {
axiosGetMock: vi.fn()
}
})
vi.mock('axios', () => {
return {
default: {
get: axiosGetMock
}
}
})
describe('main/utils/getLatestVersion', () => {
beforeEach(() => {
axiosGetMock.mockReset()
})
it('returns stable release when beta channel is older', async () => {
axiosGetMock.mockResolvedValueOnce({
data: [
{ tag_name: 'v2.5.2', prerelease: false, draft: false },
{ tag_name: 'v2.4.2-beta.0', prerelease: true, draft: false }
]
})
const version = await getLatestVersion(true)
expect(version).toBe('2.5.2')
expect(axiosGetMock).toHaveBeenCalledWith(RELEASE_URL, {
headers: {
Referer: 'https://github.com'
}
})
})
it('returns prerelease when beta channel has a newer version', async () => {
axiosGetMock.mockResolvedValueOnce({
data: [
{ tag_name: 'v2.5.2', prerelease: false, draft: false },
{ tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false }
]
})
const version = await getLatestVersion(true)
expect(version).toBe('2.6.0-beta.1')
})
it('ignores prerelease when beta updates are disabled', async () => {
axiosGetMock.mockResolvedValueOnce({
data: [
{ tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false },
{ tag_name: 'v2.5.2', prerelease: false, draft: false }
]
})
const version = await getLatestVersion(false)
expect(version).toBe('2.5.2')
})
it('fallback compares stable and beta backup metadata when beta updates are enabled', async () => {
axiosGetMock.mockRejectedValueOnce(new Error('network down'))
axiosGetMock.mockResolvedValueOnce({
data: 'version: 2.5.2'
})
axiosGetMock.mockResolvedValueOnce({
data: 'version: 2.4.2-beta.0'
})
const version = await getLatestVersion(true)
expect(version).toBe('2.5.2')
expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, {
headers: {
Referer: 'https://github.com'
}
})
expect(axiosGetMock).toHaveBeenNthCalledWith(3, `${RELEASE_URL_BACKUP}/latest.beta.yml`, {
headers: {
Referer: 'https://github.com'
}
})
})
it('fallback uses only stable backup metadata when beta updates are disabled', async () => {
axiosGetMock.mockRejectedValueOnce(new Error('network down'))
axiosGetMock.mockResolvedValueOnce({
data: 'version: 2.5.2'
})
const version = await getLatestVersion(false)
expect(version).toBe('2.5.2')
expect(axiosGetMock).toHaveBeenCalledTimes(2)
expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, {
headers: {
Referer: 'https://github.com'
}
})
})
})
+188
View File
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ServerConfig = {
port: number | string
host: string
enable: boolean
}
type ServerUploadAdapter = {
uploadClipboard: () => Promise<ImgInfo[] | Error>
uploadPaths: (paths: string[]) => Promise<ImgInfo[] | Error>
getTempDir: () => string
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}
let serverConfig: ServerConfig | undefined
let installedUploadAdapter: ServerUploadAdapter | undefined
const getConfigMock = vi.fn((key?: string) => {
if (key === 'settings.server') return serverConfig
return undefined
})
const saveConfigMock = vi.fn((patch: unknown) => {
if (!isRecord(patch)) return
const next = patch['settings.server']
if (isRecord(next) && 'port' in next && 'host' in next && 'enable' in next) {
serverConfig = next as unknown as ServerConfig
}
})
const setUploadAdapterMock = vi.fn((adapter?: ServerUploadAdapter) => {
installedUploadAdapter = adapter
})
const registerPostMock = vi.fn()
const listenMock = vi.fn()
const shutdownMock = vi.fn()
const loggerMock = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
const getAvailableWindowMock = vi.fn()
const uploadClipboardFilesWithInfoMock = vi.fn()
const uploadSelectedFilesWithInfoMock = vi.fn()
const getFormImageFolderPathMock = vi.fn(() => '/tmp/picgo-form-images')
vi.mock('@core/picgo', () => {
return {
default: {
getConfig: getConfigMock,
saveConfig: saveConfigMock,
server: {
setUploadAdapter: setUploadAdapterMock,
registerPost: registerPostMock,
listen: listenMock,
shutdown: shutdownMock
}
}
}
})
vi.mock('@core/picgo/logger', () => {
return { default: loggerMock }
})
vi.mock('apis/app/window/windowManager', () => {
return {
default: {
getAvailableWindow: getAvailableWindowMock
}
}
})
vi.mock('apis/app/uploader/apis', () => {
return {
uploadClipboardFilesWithInfo: uploadClipboardFilesWithInfoMock,
uploadSelectedFilesWithInfo: uploadSelectedFilesWithInfoMock
}
})
vi.mock('apis/core/datastore/dbChecker', () => {
return {
getFormImageFolderPath: getFormImageFolderPathMock
}
})
describe('main/server (GUI adapter to picgo-core)', () => {
beforeEach(() => {
serverConfig = undefined
installedUploadAdapter = undefined
vi.clearAllMocks()
vi.resetModules()
})
it('backfills default settings.server when missing', async () => {
serverConfig = undefined
await import('../../main/server')
expect(saveConfigMock).toHaveBeenCalledWith({
'settings.server': {
port: 36677,
host: '127.0.0.1',
enable: true
}
})
expect(serverConfig).toEqual({
port: 36677,
host: '127.0.0.1',
enable: true
})
})
it('does not install upload adapter or listen when settings.server.enable is false', async () => {
serverConfig = { port: 36677, host: '127.0.0.1', enable: false }
const mod = await import('../../main/server')
const server = mod.default
server.startup()
expect(setUploadAdapterMock).not.toHaveBeenCalled()
expect(registerPostMock).not.toHaveBeenCalled()
expect(listenMock).not.toHaveBeenCalled()
})
it('installs GUI upload adapter once and delegates listen/shutdown to picgo.server', async () => {
serverConfig = { port: '36677', host: '127.0.0.1', enable: true }
listenMock.mockResolvedValue(36677)
const mod = await import('../../main/server')
const server = mod.default
server.startup()
server.startup()
expect(setUploadAdapterMock).toHaveBeenCalledTimes(1)
expect(registerPostMock).not.toHaveBeenCalled()
expect(listenMock).toHaveBeenCalledTimes(2)
expect(listenMock).toHaveBeenCalledWith(36677, '127.0.0.1')
expect(installedUploadAdapter).toBeDefined()
server.shutdown()
expect(shutdownMock).toHaveBeenCalledTimes(1)
})
it('GUI upload adapter preserves side-effect upload helpers and returns raw ImgInfo[] for Core responses', async () => {
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
const webContents = { send: vi.fn() }
const clipboardImage: ImgInfo = {
imgUrl: 'https://raw.example/clipboard image.png',
fileName: 'clipboard image.png',
extname: '.png',
size: 123
}
const selectedImages: ImgInfo[] = [{
imgUrl: 'https://raw.example/a image.png',
fileName: 'a image.png',
extname: '.png',
origin: '/tmp/a.png',
width: 10
}]
getAvailableWindowMock.mockReturnValue({ webContents })
uploadClipboardFilesWithInfoMock.mockResolvedValue([clipboardImage])
uploadSelectedFilesWithInfoMock.mockResolvedValue(selectedImages)
const mod = await import('../../main/server')
const server = mod.default
server.startup()
expect(installedUploadAdapter).toBeDefined()
const adapter = installedUploadAdapter!
await expect(adapter.uploadClipboard()).resolves.toEqual([clipboardImage])
await expect(adapter.uploadPaths(['/tmp/a.png'])).resolves.toEqual(selectedImages)
expect(uploadClipboardFilesWithInfoMock).toHaveBeenCalledTimes(1)
expect(getAvailableWindowMock).toHaveBeenCalledTimes(1)
expect(uploadSelectedFilesWithInfoMock).toHaveBeenCalledWith(webContents, [{ path: '/tmp/a.png' }])
expect(adapter.getTempDir()).toBe('/tmp/picgo-form-images')
expect(getFormImageFolderPathMock).toHaveBeenCalledTimes(1)
})
})
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebContents } from 'electron'
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
import {
uploadClipboardFiles,
uploadClipboardFilesWithInfo,
uploadSelectedFiles,
uploadSelectedFilesWithInfo
} from '../../main/apis/app/uploader/apis'
type WebContentsStub = {
send: ReturnType<typeof vi.fn>
}
type WindowStub = {
webContents: WebContentsStub
}
const mocks = vi.hoisted(() => {
const configValues: Record<string, unknown> = {}
return {
configValues,
getConfigMock: vi.fn(),
loggerInfoMock: vi.fn(),
setWebContentsMock: vi.fn(),
uploadMock: vi.fn(),
uploadWithBuildInClipboardMock: vi.fn(),
getAvailableWindowMock: vi.fn(),
windowManagerGetMock: vi.fn(),
windowManagerHasMock: vi.fn(),
pasteTemplateMock: vi.fn(),
albumInsertMock: vi.fn(),
handleCopyUrlMock: vi.fn(),
handleUrlEncodeWithSettingMock: vi.fn(),
showNotificationMock: vi.fn(),
TMock: vi.fn()
}
})
vi.mock('@core/picgo', () => ({
default: {
getConfig: mocks.getConfigMock
}
}))
vi.mock('@core/picgo/logger', () => ({
default: {
info: mocks.loggerInfoMock
}
}))
vi.mock('../../main/apis/app/uploader', () => ({
default: {
setWebContents: mocks.setWebContentsMock
}
}))
vi.mock('apis/app/window/windowManager', () => ({
default: {
getAvailableWindow: mocks.getAvailableWindowMock,
get: mocks.windowManagerGetMock,
has: mocks.windowManagerHasMock
}
}))
vi.mock('~/main/utils/pasteTemplate', () => ({
default: mocks.pasteTemplateMock
}))
vi.mock('~/main/apis/core/datastore', () => ({
AlbumDB: {
getInstance: () => ({
insert: mocks.albumInsertMock
})
}
}))
vi.mock('~/main/utils/common', () => ({
handleCopyUrl: mocks.handleCopyUrlMock,
handleUrlEncodeWithSetting: mocks.handleUrlEncodeWithSettingMock,
showNotification: mocks.showNotificationMock
}))
vi.mock('~/main/i18n/index', () => ({
T: mocks.TMock
}))
const createWebContents = (): WebContentsStub => ({
send: vi.fn()
})
const asWebContents = (webContents: WebContentsStub): WebContents => {
return webContents as unknown as WebContents
}
describe('main uploader API helpers', () => {
let availableWindow: WindowStub
let trayWindow: WindowStub
let settingWindow: WindowStub
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
Object.keys(mocks.configValues).forEach((key) => {
delete mocks.configValues[key]
})
Object.assign(mocks.configValues, {
'settings.pasteStyle': IPasteStyle.MARKDOWN,
'settings.customLink': '$url',
'settings.useBuiltinClipboard': false
})
availableWindow = { webContents: createWebContents() }
trayWindow = { webContents: createWebContents() }
settingWindow = { webContents: createWebContents() }
mocks.getConfigMock.mockImplementation(<T>(key: string): T => mocks.configValues[key] as T)
mocks.setWebContentsMock.mockReturnValue({
upload: mocks.uploadMock,
uploadWithBuildInClipboard: mocks.uploadWithBuildInClipboardMock
})
mocks.getAvailableWindowMock.mockReturnValue(availableWindow)
mocks.windowManagerGetMock.mockImplementation((name: IWindowList) => {
if (name === IWindowList.TRAY_WINDOW) return trayWindow
if (name === IWindowList.SETTING_WINDOW) return settingWindow
return undefined
})
mocks.windowManagerHasMock.mockImplementation((name: IWindowList) => name === IWindowList.SETTING_WINDOW)
mocks.pasteTemplateMock.mockImplementation((style: IPasteStyle, item: ImgInfo, customLink?: string) => {
return `paste:${style}:${item.imgUrl ?? ''}:${customLink ?? ''}`
})
mocks.handleUrlEncodeWithSettingMock.mockImplementation((url: string) => `encoded:${url}`)
mocks.TMock.mockImplementation((key: string) => `t:${key}`)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('preserves clipboard upload side effects and returns raw image info for server adapters', async () => {
const image: ImgInfo = {
imgUrl: 'https://raw.example/clipboard image.png',
fileName: 'clipboard image.png',
extname: '.png',
origin: 'clipboard',
size: 123
}
mocks.uploadMock.mockResolvedValue([image])
const result = await uploadClipboardFilesWithInfo()
vi.runOnlyPendingTimers()
expect(result).toEqual([image])
expect(mocks.loggerInfoMock).toHaveBeenCalledWith('upload clipboard file')
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(availableWindow.webContents)
expect(mocks.uploadMock).toHaveBeenCalledWith()
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
title: 't:UPLOAD_SUCCEED',
body: image.imgUrl
})
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
expect(trayWindow.webContents.send).toHaveBeenCalledWith('clipboardFiles', [])
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', [image])
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
})
it('keeps legacy clipboard helper return value URL-encoded while preserving raw side effects', async () => {
const image: ImgInfo = {
imgUrl: 'https://raw.example/clipboard image.png',
fileName: 'clipboard image.png',
extname: '.png'
}
mocks.uploadMock.mockResolvedValue([image])
const result = await uploadClipboardFiles()
vi.runOnlyPendingTimers()
expect(result).toBe(`encoded:${image.imgUrl}`)
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenCalledWith(image.imgUrl)
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', [image])
})
it('uses the builtin clipboard upload path when configured', async () => {
const image: ImgInfo = {
imgUrl: 'https://raw.example/builtin.png',
fileName: 'builtin.png',
extname: '.png'
}
mocks.configValues['settings.useBuiltinClipboard'] = true
mocks.uploadWithBuildInClipboardMock.mockResolvedValue([image])
const result = await uploadClipboardFilesWithInfo()
vi.runOnlyPendingTimers()
expect(result).toEqual([image])
expect(mocks.uploadWithBuildInClipboardMock).toHaveBeenCalledWith()
expect(mocks.uploadMock).not.toHaveBeenCalled()
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
})
it('preserves selected-file upload side effects and returns raw image info for server adapters', async () => {
const webContents = createWebContents()
const files: IFileWithPath[] = [{ path: '/tmp/a.png' }, { path: '/tmp/b.png' }]
const images: ImgInfo[] = [
{
imgUrl: 'https://raw.example/a image.png',
fileName: 'a image.png',
extname: '.png',
origin: '/tmp/a.png',
width: 10
},
{
imgUrl: 'https://raw.example/b image.png',
fileName: 'b image.png',
extname: '.png',
origin: '/tmp/b.png',
height: 20
}
]
mocks.uploadMock.mockResolvedValue(images)
const result = await uploadSelectedFilesWithInfo(asWebContents(webContents), files)
vi.runOnlyPendingTimers()
expect(result).toEqual(images)
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(webContents)
expect(mocks.uploadMock).toHaveBeenCalledWith(['/tmp/a.png', '/tmp/b.png'])
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith([
`paste:${IPasteStyle.MARKDOWN}:${images[0].imgUrl}:$url`,
`paste:${IPasteStyle.MARKDOWN}:${images[1].imgUrl}:$url`
].join('\n'))
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
title: 't:UPLOAD_SUCCEED',
body: images[0].imgUrl
})
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
title: 't:UPLOAD_SUCCEED',
body: images[1].imgUrl
})
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(1, images[0])
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(2, images[1])
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', images)
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
})
it('keeps legacy selected-file helper return values URL-encoded', async () => {
const webContents = createWebContents()
const files: IFileWithPath[] = [{ path: '/tmp/a.png' }, { path: '/tmp/b.png' }]
const images: ImgInfo[] = [
{ imgUrl: 'https://raw.example/a image.png', fileName: 'a image.png' },
{ imgUrl: 'https://raw.example/b image.png', fileName: 'b image.png' }
]
mocks.uploadMock.mockResolvedValue(images)
const result = await uploadSelectedFiles(asWebContents(webContents), files)
vi.runOnlyPendingTimers()
expect(result).toEqual([
`encoded:${images[0].imgUrl}`,
`encoded:${images[1].imgUrl}`
])
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenNthCalledWith(1, images[0].imgUrl)
expect(mocks.handleUrlEncodeWithSettingMock).toHaveBeenNthCalledWith(2, images[1].imgUrl)
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(1, images[0])
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(2, images[1])
})
})
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SHOW_PLUGIN_PAGE_MENU, OPEN_URL } from '#/events/constants'
import { IRPCActionType } from '~/universal/types/enum'
vi.mock('@/utils/dataSender', () => ({
getConfig: vi.fn(),
invokeRPC: vi.fn(),
saveConfig: vi.fn(),
sendRPC: vi.fn(),
sendToMain: vi.fn()
}))
import { pluginsAdapter } from '@/adapters/plugins'
import {
getConfig,
invokeRPC,
saveConfig,
sendRPC,
sendToMain
} from '@/utils/dataSender'
describe('renderer/adapters plugins', () => {
const getConfigMock = vi.mocked(getConfig)
const invokeRPCMock = vi.mocked(invokeRPC)
const saveConfigMock = vi.mocked(saveConfig)
const sendRPCMock = vi.mocked(sendRPC)
const sendToMainMock = vi.mocked(sendToMain)
beforeEach(() => {
vi.clearAllMocks()
})
it('opens plugin page menu through ipc', () => {
const plugin = {
name: 'demo',
fullName: 'picgo-plugin-demo',
author: 'author',
description: 'desc',
logo: '',
version: '1.0.0',
gui: true,
config: {},
homepage: '',
ing: false
} as IPicGoPlugin
pluginsAdapter.openPluginMenu(plugin)
expect(sendToMainMock).toHaveBeenCalledWith(SHOW_PLUGIN_PAGE_MENU, plugin)
})
it('installs plugin through install RPC', async () => {
invokeRPCMock.mockResolvedValue({
success: true,
data: 'picgo-plugin-demo'
})
const result = await pluginsAdapter.installPlugin('picgo-plugin-demo')
expect(invokeRPCMock).toHaveBeenCalledWith(
IRPCActionType.INSTALL_PLUGIN,
'picgo-plugin-demo'
)
expect(result).toEqual({
success: true,
body: 'picgo-plugin-demo',
errMsg: ''
})
})
it('imports local plugin through import RPC', async () => {
invokeRPCMock.mockResolvedValue({
success: true,
data: 'picgo-plugin-local'
})
const result = await pluginsAdapter.importLocalPlugin()
expect(invokeRPCMock).toHaveBeenCalledWith(IRPCActionType.IMPORT_LOCAL_PLUGIN)
expect(result).toEqual({
success: true,
data: 'picgo-plugin-local'
})
})
it('toggles plugin enabled through enable and disable RPC', async () => {
invokeRPCMock.mockResolvedValue({
success: true,
data: 'picgo-plugin-demo'
})
await pluginsAdapter.togglePluginEnabled('picgo-plugin-demo', true)
await pluginsAdapter.togglePluginEnabled('picgo-plugin-demo', false)
expect(invokeRPCMock).toHaveBeenNthCalledWith(
1,
IRPCActionType.ENABLE_PLUGIN,
'picgo-plugin-demo'
)
expect(invokeRPCMock).toHaveBeenNthCalledWith(
2,
IRPCActionType.DISABLE_PLUGIN,
'picgo-plugin-demo'
)
})
it('updates and uninstalls plugin through RPC', async () => {
invokeRPCMock.mockResolvedValue({
success: true,
data: 'picgo-plugin-demo'
})
await pluginsAdapter.updatePlugin('picgo-plugin-demo')
await pluginsAdapter.uninstallPlugin('picgo-plugin-demo')
expect(invokeRPCMock).toHaveBeenNthCalledWith(
1,
IRPCActionType.UPDATE_PLUGIN,
'picgo-plugin-demo'
)
expect(invokeRPCMock).toHaveBeenNthCalledWith(
2,
IRPCActionType.UNINSTALL_PLUGIN,
'picgo-plugin-demo'
)
})
it('persists transformer and needReload via config helpers', async () => {
await pluginsAdapter.saveTransformer('path')
await pluginsAdapter.setNeedReload(true)
expect(saveConfigMock).toHaveBeenNthCalledWith(1, {
'picBed.transformer': 'path'
})
expect(saveConfigMock).toHaveBeenNthCalledWith(2, {
needReload: true
})
})
it('reads needReload via config helper', async () => {
getConfigMock.mockResolvedValue(true)
const result = await pluginsAdapter.getNeedReload()
expect(getConfigMock).toHaveBeenCalledWith('needReload')
expect(result).toBe(true)
})
it('reloads app through system RPC', () => {
pluginsAdapter.reloadApp()
expect(sendRPCMock).toHaveBeenCalledWith(IRPCActionType.RELOAD_APP)
})
it('opens homepage and awesome list through main events', () => {
pluginsAdapter.openPluginHomepage('https://example.com')
pluginsAdapter.openAwesomeList()
expect(sendToMainMock).toHaveBeenNthCalledWith(1, OPEN_URL, 'https://example.com')
expect(sendToMainMock).toHaveBeenNthCalledWith(
2,
OPEN_URL,
'https://github.com/PicGo/Awesome-PicGo'
)
})
})
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AlbumSource } from '~/universal/types/cloudAlbum'
import { NavType } from '@/components/main/album/utils'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('@/adapters/cloud-album', () => ({
cloudAlbumAdapter: {
getStats: vi.fn()
}
}))
vi.mock('@/components/common/album-source-switcher', () => ({
AlbumSourceSwitcher: () => null
}))
vi.mock('@/components/common/cloud-feature-highlights', () => ({
CloudFeatureHighlights: () => null
}))
vi.mock('@/components/common/cloud-refresh-button', () => ({
CloudRefreshButton: () => null
}))
vi.mock('@/components/main/album/cloud-loading', () => ({
CloudSidebarSkeleton: () => <div data-testid="cloud-sidebar-skeleton" />
}))
vi.mock('@/store', () => ({
useAppStore: {
use: {
picBeds: () => [
{ type: 'picgo-cloud', name: 'PicGo Cloud', visible: true }
]
}
}
}))
import { cloudAlbumAdapter } from '@/adapters/cloud-album'
import { AlbumSidebar } from '@/components/main/album/album-sidebar'
const buildWrapper = () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } }
})
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
return { client, Wrapper }
}
const baseProps = {
images: [],
providers: [],
navContext: { type: NavType.All, value: 'all' },
albumSource: AlbumSource.CLOUD,
isCloudAvailable: true,
onFilterChange: () => {}
}
describe('AlbumSidebar (cloud mode)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders All Photos count and provider count from stats query', async () => {
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
success: true,
data: { total: 7, types: [{ type: 'picgo-cloud', count: 7 }] }
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
const { Wrapper } = buildWrapper()
render(<AlbumSidebar {...baseProps} />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('ALBUM_ALL_PHOTOS')).toBeTruthy()
expect(screen.getAllByText('7').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('PicGo Cloud')).toBeTruthy()
})
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
})
it('shows "—" for All Photos count when stats query errors out', async () => {
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
success: false,
error: 'boom'
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
const { Wrapper } = buildWrapper()
render(<AlbumSidebar {...baseProps} />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('—')).toBeTruthy()
})
})
it('does not call stats adapter when isCloudAvailable=false', async () => {
const { Wrapper } = buildWrapper()
render(
<AlbumSidebar {...baseProps} isCloudAvailable={false} />,
{ wrapper: Wrapper }
)
// give react-query a microtask
await Promise.resolve()
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
})
it('does not call stats adapter when albumSource is LOCAL', async () => {
const { Wrapper } = buildWrapper()
render(
<AlbumSidebar {...baseProps} albumSource={AlbumSource.LOCAL} />,
{ wrapper: Wrapper }
)
await Promise.resolve()
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,179 @@
// @vitest-environment jsdom
import { act, render } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { describe, expect, it } from 'vitest'
import { evaluatePluginConfig } from 'picgo'
import {
usePluginConfigRefresh,
type RefreshConfigSchemaTarget,
} from '@/components/common/use-plugin-config-refresh'
import type { ProviderPluginConfig } from '@/components/main/providers/types'
import {
REGION_ENDPOINTS,
ENDPOINT_BUCKETS,
buildCascadeRawSchema,
} from '../fixtures/cascade-fixture'
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const DEBOUNCE = 20
const flushCascade = () => act(async () => { await wait(DEBOUNCE + 30) })
/**
* Hook-level cascade behavior. The "page-refresh saved-config preservation"
* tests previously here were moved to `provider-config-panel.spec.tsx` —
* they're now covered by mounting the real component, so the harness
* duplicates were removed. The tests below isolate cascade semantics that
* don't depend on the panel: schema diffing, default propagation, and the
* merger's stale-clearing rules.
*/
const buildInitialSchema = (): ProviderPluginConfig[] => {
return evaluatePluginConfig(
buildCascadeRawSchema() as Parameters<typeof evaluatePluginConfig>[0]
) as unknown as ProviderPluginConfig[]
}
interface HarnessState {
schema: ProviderPluginConfig[]
values: Record<string, unknown>
}
function Harness({
schemaRef,
setValuesRef,
}: {
schemaRef: { current: HarnessState | null }
setValuesRef: {
current: ((values: Record<string, unknown>) => void) | null
}
}) {
const initialSchema = buildInitialSchema()
const initialValues: Record<string, unknown> = {}
for (const field of initialSchema) {
if (field.default !== undefined) {
initialValues[field.name] = field.default
}
}
const [schema, setSchema] = useState<ProviderPluginConfig[]>(initialSchema)
const [values, setValues] = useState<Record<string, unknown>>(initialValues)
useEffect(() => {
schemaRef.current = { schema, values }
setValuesRef.current = setValues
}, [schema, values, schemaRef, setValuesRef])
const target: RefreshConfigSchemaTarget = {
target: 'uploader',
uploaderName: 'picgo-plugin-test',
}
usePluginConfigRefresh({
enabled: true,
target,
currentSchema: schema,
currentValues: values,
fetchSchema: async (_target, draftValues) =>
evaluatePluginConfig(
buildCascadeRawSchema() as Parameters<typeof evaluatePluginConfig>[0],
draftValues
) as unknown as ProviderPluginConfig[],
onSchemaUpdate: (nextSchema, nextValues) => {
setSchema(nextSchema)
setValues(nextValues)
},
debounceMs: DEBOUNCE,
})
return null
}
function setupHarness() {
const schemaRef: { current: HarnessState | null } = { current: null }
const setValuesRef: {
current: ((values: Record<string, unknown>) => void) | null
} = { current: null }
render(<Harness schemaRef={schemaRef} setValuesRef={setValuesRef} />)
return { schemaRef, setValuesRef }
}
describe('cascade hook behavior (region -> endpoint -> bucket)', () => {
it('seeds initial form with the first defaults from each cascade level', () => {
const { schemaRef } = setupHarness()
const state = schemaRef.current!
expect(state.values.region).toBe('us')
expect(state.values.endpoint).toBe('s3.us-east-1')
const bucketField = state.schema.find((f) => f.name === 'bucket')!
expect(bucketField.choices).toEqual(ENDPOINT_BUCKETS['s3.us-east-1'])
})
it('cascades both endpoint and bucket when region changes (untouched values)', async () => {
const { schemaRef, setValuesRef } = setupHarness()
// Pretend the user picked the initial defaults
await act(async () => {
setValuesRef.current!({
region: 'us',
endpoint: 's3.us-east-1',
bucket: 'us-east-prod',
pathPrefix: '',
})
})
await flushCascade()
// Switch region to 'asia' — endpoint/bucket are stale
await act(async () => {
setValuesRef.current!({
region: 'asia',
endpoint: 's3.us-east-1',
bucket: 'us-east-prod',
pathPrefix: '',
})
})
await flushCascade()
const state = schemaRef.current!
expect(state.values.endpoint).toBe(REGION_ENDPOINTS.asia[0])
expect(state.values.bucket).toBeUndefined() // not in new bucket choices
const endpointField = state.schema.find((f) => f.name === 'endpoint')!
expect(endpointField.choices).toEqual(REGION_ENDPOINTS.asia)
const bucketField = state.schema.find((f) => f.name === 'bucket')!
expect(bucketField.choices).toEqual(ENDPOINT_BUCKETS[REGION_ENDPOINTS.asia[0]])
})
it('clears user-edited values when they no longer match new choices', async () => {
const { schemaRef, setValuesRef } = setupHarness()
// User explicitly picks the second us endpoint and matching bucket
await act(async () => {
setValuesRef.current!({
region: 'us',
endpoint: 's3.us-west-2',
bucket: 'us-west-prod',
pathPrefix: '',
})
})
await flushCascade()
// Switch region to 'asia' — both endpoint and bucket are now invalid
await act(async () => {
setValuesRef.current!({
region: 'asia',
endpoint: 's3.us-west-2',
bucket: 'us-west-prod',
pathPrefix: '',
})
})
await flushCascade()
const state = schemaRef.current!
expect(state.values.endpoint).toBeUndefined()
expect(state.values.bucket).toBeUndefined()
})
})
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import {
filterValuesBySchema,
mergePluginSchema,
} from '@/components/common/merge-plugin-schema'
import type { ProviderPluginConfig } from '@/components/main/providers/types'
const inputField = (name: string, defaultValue?: string): ProviderPluginConfig => ({
name,
type: 'input',
required: false,
default: defaultValue,
})
const listField = (
name: string,
choices: string[],
defaultValue?: string
): ProviderPluginConfig => ({
name,
type: 'list',
required: false,
default: defaultValue,
choices,
})
const checkboxField = (
name: string,
choices: string[]
): ProviderPluginConfig => ({
name,
type: 'checkbox',
required: false,
choices,
})
describe('mergePluginSchema', () => {
it('keeps the old value when the list field is present and the value is still valid', () => {
const oldSchema = [listField('uploader', ['github', 'gitee'])]
const newSchema = [listField('uploader', ['github', 'gitee', 'qiniu'])]
const result = mergePluginSchema(oldSchema, newSchema, { uploader: 'github' })
expect(result.values.uploader).toBe('github')
})
it('clears the value when the field is present but the value no longer matches new choices', () => {
const oldSchema = [listField('repo', ['a', 'b'])]
const newSchema = [listField('repo', ['c', 'd'])]
const result = mergePluginSchema(oldSchema, newSchema, { repo: 'a' })
expect(result.values.repo).toBeUndefined()
})
it('resets to new default when the field type changes', () => {
const oldSchema = [listField('mode', ['a', 'b'], 'a')]
const newSchema: ProviderPluginConfig[] = [inputField('mode', 'fresh')]
const result = mergePluginSchema(oldSchema, newSchema, { mode: 'a' })
expect(result.values.mode).toBe('fresh')
})
it('keeps the orphan value in mergedValues when the field disappears', () => {
const oldSchema = [listField('uploader', ['github']), inputField('branch')]
const newSchema = [listField('uploader', ['gitee'])]
const result = mergePluginSchema(oldSchema, newSchema, {
uploader: 'github',
branch: 'dev',
})
expect(result.values.branch).toBe('dev')
})
it('restores the orphan when the field re-appears after a later refresh', () => {
const stage1OldSchema = [listField('uploader', ['github']), inputField('branch')]
const stage1NewSchema = [listField('uploader', ['gitee'])]
const intermediate = mergePluginSchema(stage1OldSchema, stage1NewSchema, {
uploader: 'gitee',
branch: 'dev',
})
const stage2OldSchema = stage1NewSchema
const stage2NewSchema = [listField('uploader', ['github']), inputField('branch')]
const final = mergePluginSchema(stage2OldSchema, stage2NewSchema, intermediate.values)
expect(final.values.branch).toBe('dev')
})
it('replaces an input field value with the new default when the old value equals the old default (untouched)', () => {
const oldSchema: ProviderPluginConfig[] = [
inputField('apiVersion', 'v1'),
]
const newSchema: ProviderPluginConfig[] = [
inputField('apiVersion', 'v2'),
]
const result = mergePluginSchema(oldSchema, newSchema, { apiVersion: 'v1' })
expect(result.values.apiVersion).toBe('v2')
})
it('keeps an input field value when the user has clearly typed something different from the default', () => {
const oldSchema: ProviderPluginConfig[] = [
inputField('apiVersion', 'v1'),
]
const newSchema: ProviderPluginConfig[] = [
inputField('apiVersion', 'v2'),
]
const result = mergePluginSchema(oldSchema, newSchema, { apiVersion: 'custom' })
expect(result.values.apiVersion).toBe('custom')
})
it('seeds new fields with their default value', () => {
const oldSchema = [listField('uploader', ['github'])]
const newSchema = [listField('uploader', ['github']), inputField('branch', 'main')]
const result = mergePluginSchema(oldSchema, newSchema, { uploader: 'github' })
expect(result.values.branch).toBe('main')
})
it('handles a mixed scenario that triggers every rule at once', () => {
const oldSchema = [
// uploader: value 'github' was edited by the user (differs from old default 'gitee')
listField('uploader', ['github', 'gitee'], 'gitee'),
// repo: value 'a' equals old default — untouched-default rule should apply on refresh
listField('repo', ['a', 'b'], 'a'),
inputField('branch', 'main'),
checkboxField('flags', ['x', 'y', 'z']),
]
const newSchema = [
listField('uploader', ['github'], 'github'),
listField('repo', ['c', 'd'], 'c'),
// 'branch' field gone, becoming an orphan
checkboxField('flags', ['x', 'z']),
listField('newField', ['p', 'q'], 'p'),
]
const result = mergePluginSchema(oldSchema, newSchema, {
uploader: 'github',
repo: 'a',
branch: 'dev',
flags: ['x', 'y'],
})
// uploader: user-edited value still in new choices -> kept
expect(result.values.uploader).toBe('github')
// repo: value matched old default, defaults changed -> reset to new default
expect(result.values.repo).toBe('c')
// branch: orphan, kept in memory
expect(result.values.branch).toBe('dev')
// flags: 'y' no longer valid -> cleared
expect(result.values.flags).toBeUndefined()
// newField: brand new, seeded with new default
expect(result.values.newField).toBe('p')
})
})
describe('filterValuesBySchema', () => {
it('drops orphan keys that are not in the current schema', () => {
const schema = [listField('uploader', ['github']), inputField('branch')]
const filtered = filterValuesBySchema(schema, {
uploader: 'github',
branch: 'main',
legacy: 'should-be-dropped',
})
expect(filtered).toEqual({ uploader: 'github', branch: 'main' })
})
})
@@ -0,0 +1,73 @@
// Regression spec for the plugin config save→read path consistency.
//
// picgo-core stores per-plugin configs at the root of the config object via
// `picgo.saveConfig({ [fullName]: values })` (same convention the CLI uses).
// The renderer's `normalizeAppConfig` rolls those root entries up into
// `appConfig.plugins[fullName]` by cross-referencing the `picgoPlugins` install
// map, so the panel and CLI read the same place.
import { describe, expect, it } from 'vitest'
import { set as lodashSet } from 'lodash'
import { normalizeAppConfig } from '@/store/utils'
describe('plugin config save path', () => {
it('rolls up root-level plugin entries into appConfig.plugins via picgoPlugins install map', () => {
const rawConfig: Record<string, unknown> = {
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
picgoPlugins: { 'picgo-plugin-test': true },
settings: {},
uploader: {},
transformer: {},
needReload: false
}
// Simulates picgo.saveConfig({ 'picgo-plugin-test': { ... } })
lodashSet(rawConfig, 'picgo-plugin-test', {
mode: 'advanced',
verbosity: 'debug',
apiVersion: 'v2'
})
const normalized = normalizeAppConfig(rawConfig as never, [])
expect(normalized?.plugins['picgo-plugin-test']).toEqual({
mode: 'advanced',
verbosity: 'debug',
apiVersion: 'v2'
})
})
it('ignores root entries that do not correspond to an installed plugin', () => {
const rawConfig: Record<string, unknown> = {
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
picgoPlugins: {},
settings: {},
uploader: {},
transformer: {},
needReload: false
}
lodashSet(rawConfig, 'picgo-plugin-test', { mode: 'advanced' })
const normalized = normalizeAppConfig(rawConfig as never, [])
// Not installed → not rolled up.
expect(normalized?.plugins['picgo-plugin-test']).toBeUndefined()
})
it('falls back to a pre-existing nested plugins entry when no root entry exists', () => {
const rawConfig: Record<string, unknown> = {
picBed: { uploader: 'smms', current: 'smms', transformer: 'path' },
picgoPlugins: { 'picgo-plugin-test': true },
settings: {},
uploader: {},
transformer: {},
plugins: {
'picgo-plugin-test': { mode: 'legacy' }
},
needReload: false
}
const normalized = normalizeAppConfig(rawConfig as never, [])
expect(normalized?.plugins['picgo-plugin-test']).toEqual({ mode: 'legacy' })
})
})
@@ -0,0 +1,332 @@
// @vitest-environment jsdom
import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { evaluatePluginConfig } from 'picgo'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('@/adapters/plugins', () => ({
pluginsAdapter: {
refreshConfigSchema: vi.fn(async () => []),
savePluginConfig: vi.fn(async () => {}),
getInstalledPlugins: vi.fn(async () => [])
}
}))
vi.mock('@/store/app-actions', () => ({
appActions: {
refreshAppConfig: vi.fn(async () => {}),
hydrateAppState: vi.fn(async () => {})
}
}))
import { PluginDetailPanel } from '@/components/main/plugins/plugin-detail-panel'
import { useAppStore } from '@/store/app-store'
import { usePluginStore } from '@/store/plugins/store'
import { pluginStoreActions } from '@/store/plugins/actions'
import { pluginsAdapter } from '@/adapters/plugins'
import { appActions } from '@/store/app-actions'
import { normalizePluginConfigSchema } from '@/components/common/normalize-plugin-schema'
import { pluginDetailTab, type PluginInstalledItem } from '@/components/main/plugins/types'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
// Raw plugin config schema mirroring the picgo-plugin-test test plugin.
// mode → verbosity (default debug/info), apiVersion (default v2/v1).
const buildRawPluginSchema = (): unknown[] => [
{
name: 'mode',
type: 'list',
required: true,
alias: 'Mode',
default: 'basic',
choices: ['basic', 'advanced']
},
{
name: 'verbosity',
type: 'list',
required: false,
alias: 'Verbosity',
dependsOn: ['mode'],
default: (answers: Record<string, unknown>) =>
(answers.mode === 'advanced' ? 'debug' : 'info'),
choices: (answers: Record<string, unknown>) =>
(answers.mode === 'advanced'
? ['silent', 'info', 'debug', 'trace']
: ['silent', 'info'])
},
{
name: 'apiVersion',
type: 'list',
required: false,
alias: 'API version',
dependsOn: ['mode'],
default: (answers: Record<string, unknown>) =>
(answers.mode === 'advanced' ? 'v2' : 'v1'),
choices: (answers: Record<string, unknown>) =>
(answers.mode === 'advanced' ? ['v2', 'v2-beta'] : ['v1', 'v1-legacy'])
}
]
const baseAppConfig = {
picBed: {
uploader: 'smms',
current: 'smms',
transformer: 'path',
proxy: '',
list: []
},
uploader: {} as Record<string, unknown>,
settings: {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: { port: 36677, host: '127.0.0.1', enable: true },
startupMode: IStartupMode.HIDE,
shortKey: {},
urlRewrite: { rules: [] }
},
picgoPlugins: {},
plugins: {} as Record<string, Record<string, unknown>>,
transformer: {},
needReload: false
}
const FULL_NAME = 'picgo-plugin-test'
function buildInstalledPlugin(): PluginInstalledItem {
const schemaEvaluated = evaluatePluginConfig(
buildRawPluginSchema() as Parameters<typeof evaluatePluginConfig>[0]
) as unknown[]
return {
name: 'test',
fullName: FULL_NAME,
author: 'tester',
description: 'for test',
logo: '',
version: '1.0.0',
gui: false,
homepage: '',
enabled: true,
hasInstall: true,
guiMenu: [],
config: {
plugin: {
name: 'test',
fullName: FULL_NAME,
config: normalizePluginConfigSchema(schemaEvaluated)
},
transformer: { name: '', fullName: undefined, config: [] }
},
uploader: undefined
}
}
function setupStore(pluginConfigInAppConfig: Record<string, unknown> | undefined) {
const installedPlugin = buildInstalledPlugin()
useAppStore.setState({
defaultPicBed: 'smms',
appConfig: {
...baseAppConfig,
plugins: pluginConfigInAppConfig
? { [FULL_NAME]: pluginConfigInAppConfig }
: {}
} as never,
picBeds: [],
providers: [],
providerSchemas: {},
pluginsInstalled: [installedPlugin],
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
hasHydrated: true,
hasSettingsHydrated: true,
picgoCloud: {
loginStatus: 'IDLE',
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
} as never)
usePluginStore.setState({
searchValue: '',
exactMatch: false,
rawSearchResults: [],
searchResults: [],
isSearching: false,
isImportingLocal: false,
isMutatingByPlugin: {},
readmeByPlugin: {}
})
return installedPlugin
}
function getFieldSelectTrigger(label: string) {
const labelEl = screen.getByText(label)
const fieldContainer = labelEl.closest('[data-slot="field"]')
if (!fieldContainer) throw new Error(`No field container for "${label}"`)
const trigger = fieldContainer.querySelector('[role="combobox"]')
if (!trigger) throw new Error(`No combobox trigger inside "${label}" field`)
return trigger as HTMLElement
}
function mountPanel(plugin: PluginInstalledItem) {
const appConfig = useAppStore.getState().appConfig
return render(
<PluginDetailPanel
appConfig={appConfig}
selectedItem={{
name: plugin.name,
fullName: plugin.fullName,
author: plugin.author,
description: plugin.description,
logo: plugin.logo,
version: plugin.version,
homepage: plugin.homepage,
hasInstall: true,
installedPlugin: plugin
} as never}
plugin={plugin}
activeTab={pluginDetailTab.Config}
availableTabs={[pluginDetailTab.Readme, pluginDetailTab.Config]}
readmeState={null}
isMutating={false}
onTabChange={vi.fn()}
/>
)
}
describe('PluginDetailPanel — config tab persistence (issue diagnostic)', () => {
beforeEach(() => {
vi.clearAllMocks()
// Schema RPC default: re-evaluate with given draftValues (simulates main).
vi.mocked(pluginsAdapter.refreshConfigSchema).mockImplementation(
async (payload) => {
const draftValues = (payload as { draftValues: Record<string, unknown> })
.draftValues
const evaluated = evaluatePluginConfig(
buildRawPluginSchema() as Parameters<typeof evaluatePluginConfig>[0],
draftValues
) as unknown[]
return normalizePluginConfigSchema(evaluated)
}
)
})
it('shows saved advanced/debug/v2 when appConfig.plugins[fullName] has those values', async () => {
// Simulates the desired post-save state: appConfig.plugins[fullName] has the
// values the user just saved.
const plugin = setupStore({
mode: 'advanced',
verbosity: 'debug',
apiVersion: 'v2'
})
mountPanel(plugin)
// No initial sync wired yet in the panel — schema stays basic-state.
// But form values come from appConfig.plugins[fullName], so mode should
// at least show 'advanced'.
await waitFor(() => {
const modeTrigger = getFieldSelectTrigger('Mode')
expect(modeTrigger.textContent).toContain('advanced')
})
// Without the schema sync, verbosity/apiVersion values are saved but
// the basic-state choices don't include them, so they render as
// placeholder (field.name). This documents the current broken state
// and gives us a failing assertion to fix.
const verbosityTrigger = getFieldSelectTrigger('Verbosity')
const apiVersionTrigger = getFieldSelectTrigger('API version')
// After fix: these should contain 'debug' / 'v2'
expect(verbosityTrigger.textContent).toContain('debug')
expect(apiVersionTrigger.textContent).toContain('v2')
})
it('shows saved values end-to-end: user toggles mode, clicks Confirm, form retains new values', async () => {
// Step 1: empty starting state — no saved plugin config.
const plugin = setupStore(undefined)
const { rerender } = mountPanel(plugin)
// Step 2: simulate the cascade — user changed mode to 'advanced',
// hook fetched new schema, form values updated to advanced/debug/v2.
// We can't drive Radix Select interactions in jsdom reliably, so
// we simulate the save call directly with the post-cascade values.
vi.mocked(pluginsAdapter.savePluginConfig).mockResolvedValueOnce(undefined)
vi.mocked(pluginsAdapter.getInstalledPlugins).mockResolvedValueOnce([
plugin as never
])
vi.mocked(appActions.refreshAppConfig).mockImplementationOnce(async () => {
// Simulate what refreshAppConfig SHOULD do after picgo-core writes the
// plugin config: appConfig.plugins[fullName] becomes the saved values.
useAppStore.setState((state) => {
if (!state.appConfig) return
state.appConfig.plugins[FULL_NAME] = {
mode: 'advanced',
verbosity: 'debug',
apiVersion: 'v2'
}
})
})
await act(async () => {
await pluginStoreActions.savePluginConfig(FULL_NAME, 'config', {
mode: 'advanced',
verbosity: 'debug',
apiVersion: 'v2'
})
})
// Re-render so the panel picks up new appConfig reference.
const nextAppConfig = useAppStore.getState().appConfig
rerender(
<PluginDetailPanel
appConfig={nextAppConfig}
selectedItem={{
name: plugin.name,
fullName: plugin.fullName,
author: plugin.author,
description: plugin.description,
logo: plugin.logo,
version: plugin.version,
homepage: plugin.homepage,
hasInstall: true,
installedPlugin: plugin
} as never}
plugin={plugin}
activeTab={pluginDetailTab.Config}
availableTabs={[pluginDetailTab.Readme, pluginDetailTab.Config]}
readmeState={null}
isMutating={false}
onTabChange={vi.fn()}
/>
)
// Verify saved values are visible after save.
await waitFor(() => {
const modeTrigger = getFieldSelectTrigger('Mode')
expect(modeTrigger.textContent).toContain('advanced')
})
expect(getFieldSelectTrigger('Verbosity').textContent).toContain('debug')
expect(getFieldSelectTrigger('API version').textContent).toContain('v2')
})
})
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
import { render, screen , fireEvent } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key
})
}))
vi.mock('@/adapters/plugins', () => ({
pluginsAdapter: {
reloadApp: vi.fn()
}
}))
import { AppReloadBar } from '@/components/common/app-reload-bar'
import { PluginSidebar, type PluginSidebarListItem } from '@/components/main/plugins/plugin-sidebar'
import { useAppStore } from '@/store/app-store'
import { usePluginStore } from '@/store/plugins/store'
import { pluginsAdapter } from '@/adapters/plugins'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
function resetStores () {
useAppStore.setState({
defaultPicBed: 'smms',
appConfig: {
picBed: {
uploader: 'smms',
current: 'smms',
transformer: 'path',
proxy: '',
list: []
},
uploader: {},
settings: {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: {
port: 36677,
host: '127.0.0.1',
enable: true
},
startupMode: IStartupMode.HIDE,
shortKey: {},
urlRewrite: {
rules: []
}
},
picgoPlugins: {},
plugins: {},
transformer: {},
needReload: false
},
picBeds: [],
providers: [],
providerSchemas: {},
pluginsInstalled: [],
settingsVersion: {
currentVersion: '2.5.3',
latestVersion: null
},
hasHydrated: true,
hasSettingsHydrated: true,
picgoCloud: {
loginStatus: 'IDLE',
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
})
usePluginStore.setState({
searchValue: '',
exactMatch: false,
rawSearchResults: [],
searchResults: [],
isSearching: false,
isImportingLocal: false,
isMutatingByPlugin: {},
readmeByPlugin: {}
})
}
function createSidebarItems (): PluginSidebarListItem[] {
return [
{
fullName: 'picgo-plugin-cloudflare-r2-xqv',
name: 'cloudflare-r2-xqv',
description: 'picgo for cloudflare-r2 storage',
author: 'xiaoqinvar',
version: '1.0.4',
logo: '',
homepage: '',
hasInstall: true,
installedPlugin: {
name: 'cloudflare-r2-xqv',
fullName: 'picgo-plugin-cloudflare-r2-xqv',
author: 'xiaoqinvar',
description: 'picgo for cloudflare-r2 storage',
logo: '',
version: '1.0.4',
gui: true,
homepage: '',
enabled: true,
hasInstall: true,
guiMenu: [],
config: {
plugin: { name: 'x', fullName: 'x', config: [] },
transformer: { name: 'path', fullName: 'path', config: [] }
}
}
}
]
}
describe('renderer/plugins components', () => {
beforeEach(() => {
vi.clearAllMocks()
resetStores()
})
it('shows reload bar only when needReload is true and triggers reload action', async () => {
const { rerender } = render(<AppReloadBar />)
expect(screen.queryByText('TIPS_NEED_RELOAD')).not.toBeInTheDocument()
useAppStore.setState((state) => {
if (state.appConfig) {
state.appConfig.needReload = true
}
})
rerender(<AppReloadBar />)
const button = await screen.findByRole('button', { name: 'TIPS_NEED_RELOAD' })
fireEvent.click(button)
expect(vi.mocked(pluginsAdapter.reloadApp)).toHaveBeenCalled()
})
it('shows import loading state and clears search input through store actions', async () => {
const onImportLocalPlugin = vi.fn()
usePluginStore.setState({
searchValue: 'cloudflare',
isImportingLocal: true
})
render(
<PluginSidebar
items={createSidebarItems()}
selectedPluginFullName={null}
onSelectPlugin={vi.fn()}
onInstallPlugin={vi.fn()}
onOpenAwesomeList={vi.fn()}
onImportLocalPlugin={onImportLocalPlugin}
onOpenPluginMenu={vi.fn()}
/>
)
expect(screen.getByRole('textbox', { name: 'SEARCH' })).toHaveValue('cloudflare')
const clearButton = screen.getByRole('button', { name: 'ALBUM_CLEAR_SELECTION' })
fireEvent.click(clearButton)
expect(usePluginStore.getState().searchValue).toBe('')
const importButtons = screen.getAllByRole('button')
const disabledImportButton = importButtons.find((button) => button.hasAttribute('disabled'))
expect(disabledImportButton).toBeDefined()
})
})
@@ -0,0 +1,365 @@
// @vitest-environment jsdom
import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { evaluatePluginConfig } from 'picgo'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-router')>(
'@tanstack/react-router'
)
return {
...actual,
useNavigate: () => vi.fn(),
useSearch: () => ({
uploader: 'picgo-plugin-test',
configId: 'config-1'
})
}
})
vi.mock('@/adapters/plugins', () => ({
pluginsAdapter: {
refreshConfigSchema: vi.fn(async () => [])
}
}))
vi.mock('@/store/providers/actions', () => ({
providerStoreActions: {
ensureSchema: vi.fn(async () => {}),
setDefaultConfig: vi.fn(),
setLoadingByProvider: vi.fn(),
saveConfig: vi.fn(),
createConfig: vi.fn()
}
}))
import { ProviderConfigPanel } from '@/components/main/providers/provider-config-panel'
import { useAppStore } from '@/store/app-store'
import { useProviderStoreBase as useProviderStore } from '@/store/providers/store'
import { pluginsAdapter } from '@/adapters/plugins'
import { normalizePluginConfigSchema } from '@/components/common/normalize-plugin-schema'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
import { buildCascadeRawSchema } from '../fixtures/cascade-fixture'
const baseAppConfig = {
picBed: {
uploader: 'picgo-plugin-test',
current: 'picgo-plugin-test',
transformer: 'path',
proxy: '',
list: []
},
uploader: {} as Record<string, unknown>,
settings: {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: { port: 36677, host: '127.0.0.1', enable: true },
startupMode: IStartupMode.HIDE,
shortKey: {},
urlRewrite: { rules: [] }
},
picgoPlugins: {},
plugins: {},
transformer: {},
needReload: false
}
function setupStoreWithSavedConfig(savedConfig: Record<string, 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[]
useAppStore.setState({
defaultPicBed: 'picgo-plugin-test',
appConfig: {
...baseAppConfig,
uploader: {
'picgo-plugin-test': {
defaultId: 'config-1',
configList: [
{
_id: 'config-1',
_configName: 'New Config',
_createdAt: 1700000000000,
_updatedAt: 1700000000000,
...savedConfig
}
]
}
}
} as never,
picBeds: [],
providers: [
{
id: 'picgo-plugin-test',
name: 'PicGo Test',
visible: true,
isDefaultUploader: true
}
],
providerSchemas: {
'picgo-plugin-test': {
id: 'picgo-plugin-test',
name: 'PicGo Test',
config: normalizePluginConfigSchema(initialSchema)
}
},
pluginsInstalled: [],
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
hasHydrated: true,
hasSettingsHydrated: true,
picgoCloud: {
loginStatus: 'IDLE',
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
} as never)
useProviderStore.setState({
isHydrating: false,
isLoadingByProvider: {},
expandedProviderIds: [],
searchValue: ''
})
}
// Mock fetchSchema to mirror what the main side would compute when given
// `draftValues` — i.e. re-evaluate the raw schema with those answers so
// downstream choices/defaults reflect them.
function mockMainSideEvaluator() {
vi.mocked(pluginsAdapter.refreshConfigSchema).mockImplementation(
async (payload) => {
const draftValues = (payload as { draftValues: Record<string, unknown> })
.draftValues
const evaluated = evaluatePluginConfig(
buildCascadeRawSchema(draftValues.region as string | undefined) as Parameters<
typeof evaluatePluginConfig
>[0],
draftValues
) as unknown[]
return normalizePluginConfigSchema(evaluated)
}
)
}
function getFieldSelectTrigger(label: string) {
const labelEl = screen.getByText(label)
const fieldContainer = labelEl.closest('[data-slot="field"]')
if (!fieldContainer) throw new Error(`No field container for "${label}"`)
const trigger = fieldContainer.querySelector('[role="combobox"]')
if (!trigger) throw new Error(`No combobox trigger inside "${label}" field`)
return trigger as HTMLElement
}
function renderPanel() {
return render(
<ProviderConfigPanel
draftConfigMap={{}}
setDraftConfigMap={vi.fn()}
onCreateConfigIntent={vi.fn()}
onDeleteConfigIntent={vi.fn()}
/>
)
}
describe('ProviderConfigPanel — page-refresh saved config rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMainSideEvaluator()
})
it('renders the saved bucket value in the Select trigger after page refresh', async () => {
setupStoreWithSavedConfig({
region: 'eu',
endpoint: 's3.eu-central-1',
bucket: 'eu-central-prod',
pathPrefix: ''
})
renderPanel()
await waitFor(
() => {
const bucketTrigger = getFieldSelectTrigger('Bucket')
expect(bucketTrigger.textContent).toContain('eu-central-prod')
},
{ timeout: 1000 }
)
expect(getFieldSelectTrigger('Region').textContent).toContain('eu')
expect(getFieldSelectTrigger('Endpoint').textContent).toContain(
's3.eu-central-1'
)
})
it('triggers an initial schema sync with the saved values when mounting', async () => {
setupStoreWithSavedConfig({
region: 'eu',
endpoint: 's3.eu-central-1',
bucket: 'eu-central-prod',
pathPrefix: ''
})
renderPanel()
await waitFor(() => {
expect(pluginsAdapter.refreshConfigSchema).toHaveBeenCalled()
})
const firstCall = vi.mocked(pluginsAdapter.refreshConfigSchema).mock
.calls[0]?.[0] as {
target: string
uploaderName: string
draftValues: Record<string, unknown>
}
expect(firstCall?.target).toBe('uploader')
expect(firstCall?.uploaderName).toBe('picgo-plugin-test')
expect(firstCall?.draftValues).toMatchObject({
region: 'eu',
endpoint: 's3.eu-central-1',
bucket: 'eu-central-prod'
})
})
it('does not clear saved values when the initial schema sync resolves', async () => {
setupStoreWithSavedConfig({
region: 'eu',
endpoint: 's3.eu-central-1',
bucket: 'eu-central-prod',
pathPrefix: ''
})
renderPanel()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
})
expect(getFieldSelectTrigger('Region').textContent).toContain('eu')
expect(getFieldSelectTrigger('Endpoint').textContent).toContain(
's3.eu-central-1'
)
expect(getFieldSelectTrigger('Bucket').textContent).toContain(
'eu-central-prod'
)
})
})
// User-driven cascade (region change → endpoint/bucket update) is verified
// at the hook level in `cascade-end-to-end.spec.tsx` — Radix Select in jsdom
// requires `@testing-library/user-event` (not installed) to reliably open
// its portal dropdown, and the panel here is a thin wrapper around the
// same hook. Adding a panel-level cascade test would duplicate coverage
// without adding signal.
describe('ProviderConfigPanel — editor field rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMainSideEvaluator()
})
it('renders a textarea for a type: editor field and binds its saved value', async () => {
// Re-setup the store with a schema that includes a type: editor field.
// We bypass the cascade fixture here because editor type is orthogonal
// to the region/endpoint/bucket cascade and we just want to verify the
// SchemaFormFields editor branch surfaces through the panel render path.
useAppStore.setState({
defaultPicBed: 'picgo-plugin-test',
appConfig: {
...baseAppConfig,
uploader: {
'picgo-plugin-test': {
defaultId: 'config-1',
configList: [
{
_id: 'config-1',
_configName: 'New Config',
_createdAt: 1700000000000,
_updatedAt: 1700000000000,
script: 'line-one\nline-two\nline-three'
}
]
}
}
} as never,
picBeds: [],
providers: [
{
id: 'picgo-plugin-test',
name: 'PicGo Test',
visible: true,
isDefaultUploader: true
}
],
providerSchemas: {
'picgo-plugin-test': {
id: 'picgo-plugin-test',
name: 'PicGo Test',
config: normalizePluginConfigSchema([
{
name: 'script',
type: 'editor',
alias: 'Compression script',
required: true,
message: 'Enter your compression script'
}
])
}
},
pluginsInstalled: [],
settingsVersion: { currentVersion: '2.5.3', latestVersion: null },
hasHydrated: true,
hasSettingsHydrated: true,
picgoCloud: {
loginStatus: 'IDLE',
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
} as never)
useProviderStore.setState({
isHydrating: false,
isLoadingByProvider: {},
expandedProviderIds: [],
searchValue: ''
})
renderPanel()
await waitFor(() => {
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.tagName).toBe('TEXTAREA')
expect(textarea.value).toBe('line-one\nline-two\nline-three')
})
})
})
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
import { SchemaFormFields } from '@/components/common/schema-form-fields'
import type { ProviderPluginConfig } from '@/components/main/providers/types'
const renderSchema = (
schema: ProviderPluginConfig[],
values: Record<string, unknown> = {},
fieldErrors: Record<string, string | undefined> = {}
) => {
const onValueChange = vi.fn()
const utils = render(
<SchemaFormFields
schema={schema}
values={values}
fieldErrors={fieldErrors}
onValueChange={onValueChange}
/>
)
return { ...utils, onValueChange }
}
describe('SchemaFormFields editor field', () => {
it('renders a textarea for type: editor', () => {
renderSchema([
{ name: 'script', type: 'editor', required: false }
])
const textarea = screen.getByRole('textbox')
expect(textarea).toBeTruthy()
expect(textarea.tagName).toBe('TEXTAREA')
expect(textarea.getAttribute('data-slot')).toBe('textarea')
})
it('binds the textarea value from values map', () => {
renderSchema(
[{ name: 'script', type: 'editor', required: false }],
{ script: 'line1\nline2\nline3' }
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.value).toBe('line1\nline2\nline3')
})
it('fires onValueChange with the full multi-line value on input', () => {
const { onValueChange } = renderSchema([
{ name: 'script', type: 'editor', required: false }
])
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'a\nb\nc' } })
expect(onValueChange).toHaveBeenCalledWith('script', 'a\nb\nc')
})
it('uses field.message as placeholder, falling back to field.name', () => {
const { rerender } = renderSchema([
{
name: 'script',
type: 'editor',
required: false,
message: 'Enter multi-line script'
}
])
let textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.placeholder).toBe('Enter multi-line script')
rerender(
<SchemaFormFields
schema={[{ name: 'script', type: 'editor', required: false }]}
values={{}}
fieldErrors={{}}
onValueChange={vi.fn()}
/>
)
textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.placeholder).toBe('script')
})
it('marks textarea as invalid when fieldErrors contains the field', () => {
renderSchema(
[{ name: 'script', type: 'editor', required: true }],
{},
{ script: 'required' }
)
const textarea = screen.getByRole('textbox')
expect(textarea.getAttribute('aria-invalid')).toBe('true')
})
it('renders a tooltip trigger when field.tips is provided', () => {
renderSchema([
{
name: 'script',
type: 'editor',
required: false,
alias: 'Compression script',
tips: 'Supports **markdown** in the tooltip.'
}
])
const tooltipTrigger = screen.getByRole('button', {
name: /Compression script tip/i
})
expect(tooltipTrigger).toBeTruthy()
})
})
describe('SchemaFormFields unknown field type', () => {
it('does not render any input control for an unrecognized type, label only', () => {
renderSchema([
{
name: 'mystery',
type: 'unknown-widget' as never,
required: false,
alias: 'Mystery field'
}
])
// The label is still rendered via FieldLabel
expect(screen.getByText('Mystery field')).toBeTruthy()
// None of the input controls render: no textbox, no combobox, no switch
expect(screen.queryByRole('textbox')).toBeNull()
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.queryByRole('switch')).toBeNull()
})
})
@@ -0,0 +1,251 @@
// @vitest-environment jsdom
import { act, render } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import {
usePluginConfigRefresh,
type RefreshConfigSchemaTarget,
} from '@/components/common/use-plugin-config-refresh'
import type { ProviderPluginConfig } from '@/components/main/providers/types'
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const inputField = (name: string): ProviderPluginConfig => ({
name,
type: 'input',
required: false,
})
const listField = (
name: string,
choices: string[],
dependsOn?: string[]
): ProviderPluginConfig => ({
name,
type: 'list',
required: false,
choices,
dependsOn,
})
interface HarnessProps {
initialSchema: ProviderPluginConfig[]
initialValues: Record<string, unknown>
target: RefreshConfigSchemaTarget
fetchSchema: (
target: RefreshConfigSchemaTarget,
draftValues: Record<string, unknown>
) => Promise<ProviderPluginConfig[]>
schemaRef: { current: ProviderPluginConfig[] }
valuesRef: { current: Record<string, unknown> }
setValuesRef: { current: ((values: Record<string, unknown>) => void) | null }
setSchemaRef: { current: ((schema: ProviderPluginConfig[]) => void) | null }
debounceMs?: number
}
function Harness({
initialSchema,
initialValues,
target,
fetchSchema,
schemaRef,
valuesRef,
setValuesRef,
setSchemaRef,
debounceMs,
}: HarnessProps) {
const [schema, setSchema] = useState<ProviderPluginConfig[]>(initialSchema)
const [values, setValues] = useState<Record<string, unknown>>(initialValues)
useEffect(() => {
schemaRef.current = schema
valuesRef.current = values
setSchemaRef.current = setSchema
setValuesRef.current = setValues
}, [schema, values, schemaRef, valuesRef, setSchemaRef, setValuesRef])
usePluginConfigRefresh({
enabled: true,
target,
currentSchema: schema,
currentValues: values,
fetchSchema,
onSchemaUpdate: (nextSchema, nextValues) => {
setSchema(nextSchema)
setValues(nextValues)
},
debounceMs,
})
return null
}
const DEBOUNCE_TICK = 20
describe('usePluginConfigRefresh', () => {
const makeRefs = () => ({
schemaRef: { current: [] as ProviderPluginConfig[] },
valuesRef: { current: {} as Record<string, unknown> },
setSchemaRef: {
current: null as ((schema: ProviderPluginConfig[]) => void) | null,
},
setValuesRef: {
current: null as ((values: Record<string, unknown>) => void) | null,
},
})
it('does not call fetchSchema when the change does not hit any dependsOn', async () => {
const fetchSchema = vi.fn(async () => [] as ProviderPluginConfig[])
const refs = makeRefs()
render(
<Harness
initialSchema={[
listField('uploader', ['github']),
inputField('proxy'), // proxy is not referenced by anyone's dependsOn
]}
initialValues={{ uploader: 'github', proxy: '' }}
target={{ target: 'uploader', uploaderName: 'imgur' }}
fetchSchema={fetchSchema}
debounceMs={DEBOUNCE_TICK}
{...refs}
/>
)
await act(async () => {
refs.setValuesRef.current!({ uploader: 'github', proxy: 'http://proxy' })
})
await act(async () => {
await wait(DEBOUNCE_TICK + 30)
})
expect(fetchSchema).not.toHaveBeenCalled()
})
it('calls fetchSchema after debounce when a dependsOn target changes', async () => {
const fetchSchema = vi.fn(async () => [
listField('uploader', ['github', 'gitee']),
listField('repo', ['x', 'y'], ['uploader']),
])
const refs = makeRefs()
render(
<Harness
initialSchema={[
listField('uploader', ['github', 'gitee']),
listField('repo', ['a', 'b'], ['uploader']),
]}
initialValues={{ uploader: 'github', repo: 'a' }}
target={{ target: 'uploader', uploaderName: 'imgur' }}
fetchSchema={fetchSchema}
debounceMs={DEBOUNCE_TICK}
{...refs}
/>
)
await act(async () => {
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
})
await act(async () => {
await wait(DEBOUNCE_TICK + 30)
})
expect(fetchSchema).toHaveBeenCalledTimes(1)
// 'repo' is a transitive dependent of the changed 'uploader' field, so it is
// stripped from the payload to let main re-cascade its default.
expect(fetchSchema).toHaveBeenCalledWith(
{ target: 'uploader', uploaderName: 'imgur' },
{ uploader: 'gitee' }
)
})
it('coalesces multiple changes inside the debounce window into one call', async () => {
const fetchSchema = vi.fn(async () => [] as ProviderPluginConfig[])
const refs = makeRefs()
render(
<Harness
initialSchema={[
listField('uploader', ['github', 'gitee']),
listField('repo', ['a'], ['uploader']),
]}
initialValues={{ uploader: 'github', repo: 'a' }}
target={{ target: 'uploader', uploaderName: 'imgur' }}
fetchSchema={fetchSchema}
debounceMs={DEBOUNCE_TICK}
{...refs}
/>
)
await act(async () => {
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
})
await act(async () => {
refs.setValuesRef.current!({ uploader: 'github', repo: 'a' })
})
await act(async () => {
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
})
await act(async () => {
await wait(DEBOUNCE_TICK + 30)
})
expect(fetchSchema).toHaveBeenCalledTimes(1)
expect(fetchSchema).toHaveBeenLastCalledWith(
{ target: 'uploader', uploaderName: 'imgur' },
{ uploader: 'gitee' }
)
})
it('passes the appropriate payload shape for each refresh target', async () => {
const initialSchema = [
listField('uploader', ['github', 'gitee']),
listField('repo', ['a'], ['uploader']),
]
// Return a schema with the same dependsOn structure so subsequent edits still fire the hook.
const fetchSchema = vi.fn(async () => initialSchema)
const runWith = async (target: RefreshConfigSchemaTarget) => {
const refs = makeRefs()
render(
<Harness
initialSchema={initialSchema}
initialValues={{ uploader: 'github', repo: 'a' }}
target={target}
fetchSchema={fetchSchema}
debounceMs={DEBOUNCE_TICK}
{...refs}
/>
)
await act(async () => {
refs.setValuesRef.current!({ uploader: 'gitee', repo: 'a' })
})
await act(async () => {
await wait(DEBOUNCE_TICK + 30)
})
}
await runWith({ target: 'plugin', pluginFullName: 'picgo-plugin-x' })
expect(fetchSchema).toHaveBeenLastCalledWith(
{ target: 'plugin', pluginFullName: 'picgo-plugin-x' },
{ uploader: 'gitee' }
)
await runWith({ target: 'transformer', pluginFullName: 'picgo-plugin-x' })
expect(fetchSchema).toHaveBeenLastCalledWith(
{ target: 'transformer', pluginFullName: 'picgo-plugin-x' },
{ uploader: 'gitee' }
)
await runWith({ target: 'uploader', uploaderName: 'imgur' })
expect(fetchSchema).toHaveBeenLastCalledWith(
{ target: 'uploader', uploaderName: 'imgur' },
{ uploader: 'gitee' }
)
})
})
@@ -0,0 +1,64 @@
/**
* Shared cascade-test fixture: a region → endpoint → bucket schema that
* mirrors the picgo-plugin-test reactive plugin. Used by both the hook-level
* cascade tests and the panel-level real-component tests.
*/
export const REGION_ENDPOINTS: Record<string, string[]> = {
us: ['s3.us-east-1', 's3.us-west-2'],
eu: ['s3.eu-west-1', 's3.eu-central-1'],
asia: ['s3.ap-southeast-1', 's3.ap-northeast-1'],
}
export const ENDPOINT_BUCKETS: Record<string, string[]> = {
's3.us-east-1': ['us-east-prod', 'us-east-staging'],
's3.us-west-2': ['us-west-prod'],
's3.eu-west-1': ['eu-west-prod', 'eu-west-archive'],
's3.eu-central-1': ['eu-central-prod'],
's3.ap-southeast-1': ['asia-sg-prod', 'asia-sg-staging'],
's3.ap-northeast-1': ['asia-tk-prod'],
}
export const buildCascadeRawSchema = (storedRegion?: string): unknown[] => [
{
name: 'region',
type: 'list',
required: true,
alias: 'Region',
default: storedRegion || 'us',
choices: ['us', 'eu', 'asia'],
},
{
name: 'endpoint',
type: 'list',
required: true,
alias: 'Endpoint',
dependsOn: ['region'],
default: (answers: Record<string, unknown>) => {
const region = (answers.region as string) || 'us'
return REGION_ENDPOINTS[region]?.[0]
},
choices: (answers: Record<string, unknown>) => {
const region = (answers.region as string) || 'us'
return REGION_ENDPOINTS[region] || []
},
},
{
name: 'bucket',
type: 'list',
required: true,
alias: 'Bucket',
dependsOn: ['endpoint'],
choices: (answers: Record<string, unknown>) => {
const endpoint = answers.endpoint as string
return ENDPOINT_BUCKETS[endpoint] || []
},
},
{
name: 'pathPrefix',
type: 'input',
required: false,
alias: 'Path prefix',
default: '',
},
]
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CloudAlbumStatsResponse } from '~/universal/types/cloudAlbum'
vi.mock('@/adapters/cloud-album', () => ({
cloudAlbumAdapter: {
getStats: vi.fn()
}
}))
vi.mock('@/queries/query-client', () => ({
rendererQueryClient: {
invalidateQueries: vi.fn()
}
}))
import { cloudAlbumAdapter } from '@/adapters/cloud-album'
import { rendererQueryClient } from '@/queries/query-client'
import {
invalidateCloudAlbumStatsQuery,
PicGoCloudAlbumStatsQueryKeys,
useCloudAlbumStatsQuery
} from '@/queries/picgo-cloud-album-stats'
const SAMPLE_STATS: CloudAlbumStatsResponse = {
total: 12,
types: [
{ type: 'picgo-cloud', count: 8 },
{ type: 'github', count: 4 }
]
}
const buildWrapper = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 }
}
})
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
return { client, Wrapper }
}
describe('useCloudAlbumStatsQuery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('returns parsed stats when adapter resolves with success', async () => {
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
success: true,
data: SAMPLE_STATS
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudAlbumStatsQuery(), {
wrapper: Wrapper
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(SAMPLE_STATS)
})
it('surfaces error state when adapter resolves with success: false', async () => {
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
success: false,
error: 'stats-fetch-failed'
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudAlbumStatsQuery(), {
wrapper: Wrapper
})
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBeInstanceOf(Error)
expect((result.current.error as Error).message).toBe('stats-fetch-failed')
})
it('does not call adapter when enabled is false', async () => {
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudAlbumStatsQuery({ enabled: false }), {
wrapper: Wrapper
})
// give react-query a microtask to schedule (it shouldn't)
await Promise.resolve()
expect(cloudAlbumAdapter.getStats).not.toHaveBeenCalled()
expect(result.current.fetchStatus).toBe('idle')
})
it('calls adapter when enabled is omitted (default true)', async () => {
vi.mocked(cloudAlbumAdapter.getStats).mockResolvedValue({
success: true,
data: SAMPLE_STATS
} as unknown as Awaited<ReturnType<typeof cloudAlbumAdapter.getStats>>)
const { Wrapper } = buildWrapper()
renderHook(() => useCloudAlbumStatsQuery(), { wrapper: Wrapper })
await waitFor(() => {
expect(cloudAlbumAdapter.getStats).toHaveBeenCalledOnce()
})
})
})
describe('invalidateCloudAlbumStatsQuery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('invalidates the canonical stats query key', async () => {
vi.mocked(rendererQueryClient.invalidateQueries).mockResolvedValue(undefined)
await invalidateCloudAlbumStatsQuery()
expect(rendererQueryClient.invalidateQueries).toHaveBeenCalledOnce()
expect(rendererQueryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: PicGoCloudAlbumStatsQueryKeys.stats
})
})
})
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
IPicGoCloudConfigSyncSessionStatus,
IPicGoCloudEncryptionMethod,
type IPicGoCloudConfigSyncState
} from '~/universal/types/cloudConfigSync'
vi.mock('@/adapters/cloud', () => ({
cloudAdapter: {
getConfigSyncState: vi.fn()
}
}))
vi.mock('@/queries/picgo-cloud', () => ({
usePicGoCloudUserInfo: vi.fn()
}))
vi.mock('@/queries/query-client', () => ({
rendererQueryClient: {
setQueryData: vi.fn(),
getQueryData: vi.fn()
}
}))
import { cloudAdapter } from '@/adapters/cloud'
import { usePicGoCloudUserInfo } from '@/queries/picgo-cloud'
import { rendererQueryClient } from '@/queries/query-client'
import {
PicGoCloudConfigSyncQueryKeys,
setCloudConfigSyncStateQueryData,
updateCloudConfigSyncStateQueryData,
useCloudConfigSyncStateQuery
} from '@/queries/picgo-cloud-config-sync'
const SAMPLE_STATE: IPicGoCloudConfigSyncState = {
sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE,
encryptionMethod: IPicGoCloudEncryptionMethod.AUTO,
lastSyncedAt: '2026-05-05T10:35:09.000Z'
}
const buildWrapper = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 }
}
})
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
return { client, Wrapper }
}
describe('picgo-cloud-config-sync helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('setCloudConfigSyncStateQueryData', () => {
it('writes state to query cache under the canonical key', () => {
setCloudConfigSyncStateQueryData(SAMPLE_STATE)
expect(rendererQueryClient.setQueryData).toHaveBeenCalledOnce()
expect(rendererQueryClient.setQueryData).toHaveBeenCalledWith(
PicGoCloudConfigSyncQueryKeys.state,
SAMPLE_STATE
)
})
})
describe('updateCloudConfigSyncStateQueryData', () => {
it('passes the updater function to setQueryData', () => {
const updater = vi.fn(
(prev: IPicGoCloudConfigSyncState | undefined): IPicGoCloudConfigSyncState => ({
...(prev ?? { sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE }),
sessionStatus: IPicGoCloudConfigSyncSessionStatus.SYNCING
})
)
updateCloudConfigSyncStateQueryData(updater)
expect(rendererQueryClient.setQueryData).toHaveBeenCalledOnce()
const callArgs = vi.mocked(rendererQueryClient.setQueryData).mock.calls[0]
expect(callArgs[0]).toEqual(PicGoCloudConfigSyncQueryKeys.state)
expect(callArgs[1]).toBe(updater)
})
})
})
describe('useCloudConfigSyncStateQuery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('does not call adapter when user is not logged in', async () => {
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
userInfo: undefined,
isPaid: false,
isFetched: true
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
wrapper: Wrapper
})
// 等一个 microtask 让 react-query 有机会 schedule fetch
await Promise.resolve()
expect(cloudAdapter.getConfigSyncState).not.toHaveBeenCalled()
expect(result.current.fetchStatus).toBe('idle')
})
it('returns parsed state when adapter resolves with success', async () => {
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
userInfo: { user: 'tester', plan: 1 },
isPaid: true,
isFetched: true
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
vi.mocked(cloudAdapter.getConfigSyncState).mockResolvedValue({
success: true,
data: SAMPLE_STATE
} as unknown as Awaited<ReturnType<typeof cloudAdapter.getConfigSyncState>>)
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
wrapper: Wrapper
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(cloudAdapter.getConfigSyncState).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(SAMPLE_STATE)
})
it('surfaces error state when adapter resolves with success: false', async () => {
vi.mocked(usePicGoCloudUserInfo).mockReturnValue({
userInfo: { user: 'tester', plan: 0 },
isPaid: false,
isFetched: true
} as unknown as ReturnType<typeof usePicGoCloudUserInfo>)
vi.mocked(cloudAdapter.getConfigSyncState).mockResolvedValue({
success: false,
error: 'boom'
} as unknown as Awaited<ReturnType<typeof cloudAdapter.getConfigSyncState>>)
const { Wrapper } = buildWrapper()
const { result } = renderHook(() => useCloudConfigSyncStateQuery(), {
wrapper: Wrapper
})
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBeInstanceOf(Error)
expect((result.current.error as Error).message).toBe('boom')
})
})
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { createMemoryHistory } from '@tanstack/history'
import {
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
redirect,
useMatchRoute
} from '@tanstack/react-router'
import { render, screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
function AppShellMock () {
const matchRoute = useMatchRoute()
const isActive = (to: string) => {
return Boolean(matchRoute({ to, fuzzy: true, pending: false }))
}
return (
<div>
<button aria-current={isActive('/main/dashboard') ? 'page' : undefined}>SIDEBAR_DASHBOARD</button>
<button aria-current={isActive('/main/providers') ? 'page' : undefined}>ALBUM_PROVIDERS</button>
<button aria-current={isActive('/main/plugins') ? 'page' : undefined}>SIDEBAR_PLUGINS</button>
<button aria-current={isActive('/main/settings/settings') ? 'page' : undefined}>SETTINGS</button>
<button>MORE</button>
<Outlet />
</div>
)
}
function createTestRouter (initialEntry: string) {
const rootRoute = createRootRoute({
component: Outlet
})
const mainRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/main',
component: AppShellMock
})
const mainIndexRoute = createRoute({
getParentRoute: () => mainRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/main/dashboard', replace: true })
}
})
const dashboardRoute = createRoute({
getParentRoute: () => mainRoute,
path: '/dashboard',
component: () => <div title='DASHBOARD_HISTORY_PANEL_TITLE'>dashboard</div>
})
const providersRoute = createRoute({
getParentRoute: () => mainRoute,
path: '/providers',
component: () => <div>providers</div>
})
const pluginsRoute = createRoute({
getParentRoute: () => mainRoute,
path: '/plugins',
component: () => <div>plugins</div>
})
const settingsRoute = createRoute({
getParentRoute: () => mainRoute,
path: '/settings/settings',
component: () => <div>settings</div>
})
const routeTree = rootRoute.addChildren([
mainRoute.addChildren([
mainIndexRoute,
dashboardRoute,
providersRoute,
pluginsRoute,
settingsRoute
])
])
return createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: [initialEntry]
})
})
}
function renderRoute (initialEntry: string) {
const router = createTestRouter(initialEntry)
render(<RouterProvider router={router} />)
return router
}
describe('renderer app shell routing', () => {
test('redirects /main/ to dashboard and renders dashboard-only history panel', async () => {
renderRoute('/main/')
expect(await screen.findByRole('button', { name: 'SIDEBAR_DASHBOARD' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByTitle('DASHBOARD_HISTORY_PANEL_TITLE')).toBeInTheDocument()
})
test('drives active sidebar state from the current URL', async () => {
renderRoute('/main/providers')
expect(await screen.findByRole('button', { name: 'ALBUM_PROVIDERS' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('button', { name: 'SIDEBAR_DASHBOARD' })).not.toHaveAttribute('aria-current')
})
test('renders the v3-style shell navigation groups on desktop routes', async () => {
renderRoute('/main/dashboard')
expect(await screen.findByRole('button', { name: 'SIDEBAR_PLUGINS' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'SETTINGS' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'MORE' })).toBeInTheDocument()
})
})
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IConfig } from 'picgo'
import { getConfig, getPicBeds } from '@/utils/dataSender'
import {
appActions,
PicGoCloudLoginStatusValues,
useStore
} from '@/store'
vi.mock('@/utils/dataSender', () => {
return {
getConfig: vi.fn(),
getPicBeds: vi.fn(),
saveConfig: vi.fn()
}
})
const resetStore = () => {
useStore.setState({
defaultPicBed: 'smms',
appConfig: null,
picBeds: [],
picgoCloud: {
loginStatus: PicGoCloudLoginStatusValues.Idle,
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
})
}
describe('renderer/store appConfig', () => {
const getConfigMock = vi.mocked(getConfig)
const getPicBedsMock = vi.mocked(getPicBeds)
beforeEach(() => {
vi.clearAllMocks()
resetStore()
})
it('refreshAppConfig updates appConfig and defaultPicBed', async () => {
const config: IConfig = {
picBed: {
uploader: 'github',
current: 'smms'
},
picgoPlugins: {}
}
getConfigMock.mockResolvedValue(config)
await appActions.refreshAppConfig()
const nextState = useStore.getState()
expect(nextState.appConfig?.picBed.uploader).toBe('github')
expect(nextState.appConfig?.picBed.current).toBe('smms')
expect(nextState.appConfig?.settings.autoCopyUrl).toBe(true)
expect(nextState.defaultPicBed).toBe('github')
})
it('refreshPicBeds updates picBeds', async () => {
const picBeds: IPicBedType[] = [
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: true }
]
getPicBedsMock.mockResolvedValue(picBeds)
await appActions.refreshPicBeds()
expect(useStore.getState().picBeds).toEqual(picBeds)
})
})
@@ -0,0 +1,341 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/adapters/plugins', () => ({
pluginsAdapter: {
installPlugin: vi.fn(),
importLocalPlugin: vi.fn(),
uninstallPlugin: vi.fn(),
updatePlugin: vi.fn(),
togglePluginEnabled: vi.fn(),
saveTransformer: vi.fn(),
setNeedReload: vi.fn(),
getInstalledPlugins: vi.fn(),
savePluginConfig: vi.fn(),
fetchPluginReadme: vi.fn(),
searchPlugins: vi.fn()
}
}))
vi.mock('@/store/app-actions', () => ({
appActions: {
hydrateAppState: vi.fn(),
refreshAppConfig: vi.fn()
}
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn()
}
}))
import { pluginsAdapter } from '@/adapters/plugins'
import { pluginReadmeStatus, type PluginInstalledItem } from '@/components/main/plugins/types'
import { appActions } from '@/store/app-actions'
import { useAppStore } from '@/store/app-store'
import { pluginStoreActions } from '@/store/plugins/actions'
import { usePluginStore } from '@/store/plugins/store'
import { toast } from 'sonner'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
function createInstalledPlugin (overrides?: Partial<PluginInstalledItem>): PluginInstalledItem {
return {
name: 'cloudflare-r2-xqv',
fullName: 'picgo-plugin-cloudflare-r2-xqv',
author: 'xiaoqinvar',
description: 'picgo for cloudflare-r2 storage',
logo: 'https://example.com/logo.png',
version: '1.0.4',
gui: true,
homepage: 'https://example.com',
enabled: true,
hasInstall: true,
guiMenu: [],
config: {
plugin: {
name: 'cloudflare-r2-xqv',
fullName: 'picgo-plugin-cloudflare-r2-xqv',
config: []
},
transformer: {
name: 'path',
fullName: 'path',
config: []
}
},
uploader: {
id: 'cloudflare',
name: 'cloudflare',
schema: [],
configState: {
configList: [],
defaultId: ''
}
},
...overrides
}
}
function resetStores () {
useAppStore.setState({
defaultPicBed: 'smms',
appConfig: {
picBed: {
uploader: 'smms',
current: 'smms',
transformer: 'path',
proxy: '',
list: []
},
uploader: {},
settings: {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: {
port: 36677,
host: '127.0.0.1',
enable: true
},
startupMode: IStartupMode.HIDE,
shortKey: {},
urlRewrite: {
rules: []
}
},
picgoPlugins: {},
plugins: {},
transformer: {},
needReload: false
},
picBeds: [],
providers: [],
providerSchemas: {},
pluginsInstalled: [],
settingsVersion: {
currentVersion: '2.5.3',
latestVersion: null
},
hasHydrated: true,
hasSettingsHydrated: true,
picgoCloud: {
loginStatus: 'IDLE',
loginError: null,
hasAgreedToTermsAndPrivacy: false
}
})
usePluginStore.setState({
searchValue: '',
exactMatch: false,
rawSearchResults: [],
searchResults: [],
isSearching: false,
isImportingLocal: false,
isMutatingByPlugin: {},
readmeByPlugin: {}
})
}
describe('renderer/store plugins', () => {
const installPluginMock = vi.mocked(pluginsAdapter.installPlugin)
const importLocalPluginMock = vi.mocked(pluginsAdapter.importLocalPlugin)
const uninstallPluginMock = vi.mocked(pluginsAdapter.uninstallPlugin)
const updatePluginMock = vi.mocked(pluginsAdapter.updatePlugin)
const togglePluginEnabledMock = vi.mocked(pluginsAdapter.togglePluginEnabled)
const getInstalledPluginsMock = vi.mocked(pluginsAdapter.getInstalledPlugins)
const fetchPluginReadmeMock = vi.mocked(pluginsAdapter.fetchPluginReadme)
const hydrateAppStateMock = vi.mocked(appActions.hydrateAppState)
const refreshAppConfigMock = vi.mocked(appActions.refreshAppConfig)
const toastSuccessMock = vi.mocked(toast.success)
beforeEach(() => {
vi.clearAllMocks()
resetStores()
})
it('installs plugin and shows success toast', async () => {
const installedPlugin = createInstalledPlugin()
installPluginMock.mockResolvedValue({
success: true,
body: installedPlugin.fullName,
errMsg: ''
})
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
await pluginStoreActions.installPlugin(installedPlugin.fullName)
expect(installPluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
expect(hydrateAppStateMock).toHaveBeenCalled()
expect(useAppStore.getState().pluginsInstalled).toHaveLength(1)
expect(toastSuccessMock).toHaveBeenCalled()
})
it('toggles plugin enabled state and shows success toast', async () => {
const installedPlugin = createInstalledPlugin()
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
togglePluginEnabledMock.mockResolvedValue({
success: true,
data: installedPlugin.fullName
})
getInstalledPluginsMock.mockResolvedValue([
{ ...installedPlugin, enabled: false } as unknown as IPicGoPlugin
])
await pluginStoreActions.setPluginEnabled(installedPlugin.fullName, false)
expect(togglePluginEnabledMock).toHaveBeenCalledWith(installedPlugin.fullName, false)
expect(useAppStore.getState().pluginsInstalled[0]?.enabled).toBe(false)
expect(toastSuccessMock).toHaveBeenCalled()
})
it('updates plugin and marks needReload', async () => {
const installedPlugin = createInstalledPlugin()
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
updatePluginMock.mockResolvedValue({
success: true,
data: installedPlugin.fullName
})
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
await pluginStoreActions.updatePlugin(installedPlugin.fullName)
expect(updatePluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
expect(useAppStore.getState().appConfig?.needReload).toBe(true)
expect(toastSuccessMock).toHaveBeenCalled()
})
it('uninstalls plugin and marks needReload', async () => {
const installedPlugin = createInstalledPlugin()
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
uninstallPluginMock.mockResolvedValue({
success: true,
data: installedPlugin.fullName
})
getInstalledPluginsMock.mockResolvedValue([])
await pluginStoreActions.uninstallPlugin(installedPlugin.fullName)
expect(uninstallPluginMock).toHaveBeenCalledWith(installedPlugin.fullName)
expect(useAppStore.getState().pluginsInstalled).toEqual([])
expect(useAppStore.getState().appConfig?.needReload).toBe(true)
expect(toastSuccessMock).toHaveBeenCalled()
})
it('imports local plugin via RPC result and selects installed plugin data', async () => {
const installedPlugin = createInstalledPlugin()
importLocalPluginMock.mockResolvedValue({
success: true,
data: installedPlugin.fullName
})
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
const result = await pluginStoreActions.importLocalPlugin()
expect(importLocalPluginMock).toHaveBeenCalled()
expect(result?.installedPlugin.fullName).toBe(installedPlugin.fullName)
expect(usePluginStore.getState().isImportingLocal).toBe(false)
expect(toastSuccessMock).toHaveBeenCalled()
})
it('returns null when local import is cancelled', async () => {
importLocalPluginMock.mockResolvedValue({
success: true,
data: null
})
const result = await pluginStoreActions.importLocalPlugin()
expect(result).toBeNull()
expect(getInstalledPluginsMock).not.toHaveBeenCalled()
expect(usePluginStore.getState().isImportingLocal).toBe(false)
})
it('filters exact-match search results from cached raw results', () => {
usePluginStore.setState({
searchValue: 'cloudflare',
exactMatch: false,
rawSearchResults: [
{
name: 'cloudflare-r2-xqv',
fullName: 'picgo-plugin-cloudflare-r2-xqv',
author: 'foo',
description: 'foo',
logo: '',
version: '1.0.0',
homepage: '',
gui: true,
hasInstall: false
},
{
name: 'other',
fullName: 'picgo-plugin-other',
author: 'bar',
description: 'bar',
logo: '',
version: '1.0.0',
homepage: '',
gui: true,
hasInstall: false
}
],
searchResults: []
})
pluginStoreActions.toggleExactMatch()
expect(usePluginStore.getState().exactMatch).toBe(true)
expect(usePluginStore.getState().searchResults).toHaveLength(1)
expect(usePluginStore.getState().searchResults[0]?.fullName).toBe(
'picgo-plugin-cloudflare-r2-xqv'
)
})
it('fetches README into ready state', async () => {
fetchPluginReadmeMock.mockResolvedValue('# hello')
await pluginStoreActions.fetchPluginReadme('picgo-plugin-cloudflare-r2-xqv')
expect(usePluginStore.getState().readmeByPlugin['picgo-plugin-cloudflare-r2-xqv']).toEqual({
status: pluginReadmeStatus.Ready,
content: '# hello',
errorMessage: null
})
})
it('refreshes app config after saving plugin config', async () => {
const installedPlugin = createInstalledPlugin()
useAppStore.setState({ pluginsInstalled: [installedPlugin] })
vi.mocked(pluginsAdapter.savePluginConfig).mockResolvedValue(undefined)
getInstalledPluginsMock.mockResolvedValue([installedPlugin as unknown as IPicGoPlugin])
await pluginStoreActions.savePluginConfig(installedPlugin.fullName, 'config', {
token: 'abc'
})
expect(vi.mocked(pluginsAdapter.savePluginConfig)).toHaveBeenCalledWith(
'plugin',
installedPlugin.config.plugin.fullName,
{ token: 'abc' }
)
expect(refreshAppConfigMock).toHaveBeenCalled()
})
})
@@ -0,0 +1,282 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppConfig } from '@/components/main/providers/types'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
vi.mock('@/adapters/providers', () => ({
providersAdapter: {
getProviderSchema: vi.fn(),
getProviderConfigList: vi.fn(),
selectProviderConfig: vi.fn(),
changeCurrentUploader: vi.fn(),
deleteProviderConfig: vi.fn(),
copyProviderConfig: vi.fn(),
saveProviderConfig: vi.fn()
}
}))
vi.mock('@/store/app-actions', () => ({
appActions: {
hydrateAppState: vi.fn(),
ensureHydrated: vi.fn(),
ensureSettingsHydrated: vi.fn(),
setDefaultPicBed: vi.fn()
}
}))
import { providersAdapter } from '@/adapters/providers'
import { appActions } from '@/store/app-actions'
import { useAppStore } from '@/store/app-store'
import { useProviderStore } from '@/store/providers/store'
import { providerStoreActions } from '@/store/providers/actions'
function createAppConfig (): AppConfig {
return {
picBed: {
uploader: 'github',
current: 'github',
transformer: 'path',
proxy: '',
list: [
{ type: 'github', name: 'GitHub', visible: true }
]
},
uploader: {
github: {
defaultId: 'cfg-1',
configList: [
{
_id: 'cfg-1',
_configName: 'Default',
_createdAt: 1,
_updatedAt: 1,
token: 'old-token'
}
]
}
},
settings: {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: {
port: 36677,
host: '127.0.0.1',
enable: true
},
startupMode: IStartupMode.SHOW_MAIN_WINDOW,
shortKey: {
'picgo:upload': {
name: 'upload',
label: 'Quick Upload',
key: 'CommandOrControl+Shift+U',
enable: true
}
},
urlRewrite: {
rules: []
}
},
picgoPlugins: {},
plugins: {},
transformer: {},
needReload: false
}
}
function resetStores () {
useAppStore.setState((state) => {
state.appConfig = createAppConfig()
state.providers = [
{
id: 'github',
name: 'GitHub',
visible: true,
isDefaultUploader: true
}
]
state.providerSchemas = {}
state.hasHydrated = true
})
useProviderStore.setState((state) => {
state.isHydrating = false
state.isLoadingByProvider = {}
state.expandedProviderIds = []
state.searchValue = ''
})
}
describe('renderer/store providers', () => {
const getProviderSchemaMock = vi.mocked(providersAdapter.getProviderSchema)
const selectProviderConfigMock = vi.mocked(providersAdapter.selectProviderConfig)
const changeCurrentUploaderMock = vi.mocked(providersAdapter.changeCurrentUploader)
const deleteProviderConfigMock = vi.mocked(providersAdapter.deleteProviderConfig)
const copyProviderConfigMock = vi.mocked(providersAdapter.copyProviderConfig)
const saveProviderConfigMock = vi.mocked(providersAdapter.saveProviderConfig)
const hydrateAppStateMock = vi.mocked(appActions.hydrateAppState)
const setDefaultPicBedMock = vi.mocked(appActions.setDefaultPicBed)
beforeEach(() => {
vi.clearAllMocks()
resetStores()
})
it('loads provider schema into global store', async () => {
getProviderSchemaMock.mockResolvedValue({
name: 'GitHub',
config: [
{
name: 'token',
type: 'password',
required: true,
alias: 'Token'
}
]
})
const schema = await providerStoreActions.ensureSchema('github')
expect(schema).toEqual({
id: 'github',
name: 'GitHub',
config: [
{
name: 'token',
type: 'password',
required: true,
alias: 'Token',
default: undefined,
message: undefined,
prefix: undefined,
tips: undefined,
confirmText: undefined,
cancelText: undefined,
choices: undefined
}
]
})
expect(useAppStore.getState().providerSchemas.github).toEqual(schema)
expect(useProviderStore.getState().isLoadingByProvider.github).toBe(false)
})
it('maps config id to name for default/select dashboard actions', async () => {
selectProviderConfigMock.mockResolvedValue('cfg-1')
const selectedId = await providerStoreActions.setDefaultConfig('github', 'cfg-1')
expect(selectProviderConfigMock).toHaveBeenCalledWith('github', 'Default')
expect(hydrateAppStateMock).toHaveBeenCalled()
expect(selectedId).toBe('cfg-1')
await providerStoreActions.selectDashboardProviderConfig('github', 'cfg-1')
expect(changeCurrentUploaderMock).toHaveBeenCalledWith('github', 'Default')
})
it('creates, copies, deletes and saves provider configs with real adapter calls', async () => {
saveProviderConfigMock.mockImplementation(async (_type, configId, values) => {
if (!configId && values._configName === 'New Config') {
useAppStore.setState((state) => {
if (!state.appConfig) {
return
}
state.appConfig.uploader.github.configList.push({
_id: 'cfg-2',
_configName: 'New Config',
_createdAt: 2,
_updatedAt: 2
})
})
}
})
const createdId = await providerStoreActions.createConfig('github', 'New Config')
expect(saveProviderConfigMock).toHaveBeenCalledWith('github', '', {
_configName: 'New Config'
})
expect(createdId).toBe('cfg-2')
await providerStoreActions.saveConfig('github', 'cfg-2', {
token: 'new-token'
})
expect(saveProviderConfigMock).toHaveBeenLastCalledWith('github', 'cfg-2', {
token: 'new-token',
_configName: 'New Config'
})
copyProviderConfigMock.mockResolvedValue({
defaultId: 'cfg-1',
configList: [
{
_id: 'cfg-1',
_configName: 'Default',
_createdAt: 1,
_updatedAt: 1
},
{
_id: 'cfg-3',
_configName: 'Default - Copy',
_createdAt: 3,
_updatedAt: 3
}
]
})
const copiedId = await providerStoreActions.copyConfig(
'github',
'cfg-1',
'Default - Copy'
)
expect(copyProviderConfigMock).toHaveBeenCalledWith(
'github',
'Default',
'Default - Copy'
)
expect(copiedId).toBe('cfg-3')
deleteProviderConfigMock.mockResolvedValue({
defaultId: 'cfg-1',
configList: [
{
_id: 'cfg-1',
_configName: 'Default',
_createdAt: 1,
_updatedAt: 1
}
]
})
const fallbackId = await providerStoreActions.deleteConfig('github', 'cfg-2')
expect(deleteProviderConfigMock).toHaveBeenCalledWith('github', 'New Config')
expect(fallbackId).toBe('cfg-1')
})
it('forwards default provider changes to app actions', async () => {
await providerStoreActions.setDefaultProvider('github')
expect(setDefaultPicBedMock).toHaveBeenCalledWith('github')
})
})
@@ -0,0 +1,345 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IConfig } from 'picgo'
import type { AppConfig } from '@/components/main/providers/types'
import type { SettingsUrlRewriteRule } from '@/components/main/settings/utils'
import { IPasteStyle, IStartupMode } from '~/universal/types/enum'
vi.mock('@/adapters/app-config', () => ({
appConfigAdapter: {
getAppConfig: vi.fn(),
getPicBeds: vi.fn(),
saveConfig: vi.fn(),
subscribeToUpdates: vi.fn(() => () => {})
}
}))
vi.mock('@/adapters/settings', () => ({
settingsAdapter: {
savePatch: vi.fn(),
saveSingle: vi.fn(),
setAutoStart: vi.fn(),
showDockIcon: vi.fn(),
showMenubarIcon: vi.fn(),
openConfigFile: vi.fn(),
openLogFile: vi.fn(),
openExternalUrl: vi.fn(),
updateServer: vi.fn(),
updateCustomLink: vi.fn(),
checkLatestVersion: vi.fn(),
loadShortcuts: vi.fn(),
toggleShortcutModifiedMode: vi.fn(),
toggleShortcutEnabled: vi.fn(),
updateShortcut: vi.fn(),
loadUrlRewriteRules: vi.fn(),
saveUrlRewriteRules: vi.fn(),
updateVisiblePicBeds: vi.fn()
}
}))
vi.mock('@/utils/storage', () => ({
rendererStorage: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
import { appConfigAdapter } from '@/adapters/app-config'
import { settingsAdapter } from '@/adapters/settings'
import { appActions, settingsStoreActions, useStore } from '@/store'
function createAppConfig (
overrides?: Partial<AppConfig>,
settingsOverrides?: Partial<AppConfig['settings']>
): AppConfig {
const {
settings: overriddenSettings,
...configOverrides
} = overrides ?? {}
const baseSettings: AppConfig['settings'] = {
appearance: 'auto',
pasteStyle: IPasteStyle.MARKDOWN,
showUpdateTip: false,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: false,
showDockIcon: true,
showMenubarIcon: true,
customLink: '$url',
npmProxy: '',
npmRegistry: '',
server: {
port: 36677,
host: '127.0.0.1',
enable: true
},
startupMode: IStartupMode.HIDE,
shortKey: {
'picgo:upload': {
name: 'upload',
label: 'Quick Upload',
key: 'CommandOrControl+Shift+U',
enable: true
}
},
urlRewrite: {
rules: []
}
}
return {
picBed: {
uploader: 'smms',
current: 'smms',
transformer: '',
proxy: '',
list: [
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: false }
]
},
uploader: {},
settings: {
...baseSettings,
...overriddenSettings,
...settingsOverrides
},
picgoPlugins: {},
plugins: {},
transformer: {},
needReload: false,
...configOverrides
}
}
function resetStore (overrides?: Partial<AppConfig>) {
useStore.setState({
hasHydrated: true,
hasSettingsHydrated: true,
appConfig: createAppConfig(overrides),
picBeds: [
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: false }
],
settingsVersion: {
currentVersion: '3.0.0',
latestVersion: null
}
})
}
describe('renderer/store settings', () => {
const getAppConfigMock = vi.mocked(appConfigAdapter.getAppConfig)
const getPicBedsMock = vi.mocked(appConfigAdapter.getPicBeds)
const saveAppConfigMock = vi.mocked(appConfigAdapter.saveConfig)
const savePatchMock = vi.mocked(settingsAdapter.savePatch)
const setAutoStartMock = vi.mocked(settingsAdapter.setAutoStart)
const updateVisiblePicBedsMock = vi.mocked(settingsAdapter.updateVisiblePicBeds)
const updateShortcutMock = vi.mocked(settingsAdapter.updateShortcut)
const toggleShortcutEnabledMock = vi.mocked(settingsAdapter.toggleShortcutEnabled)
const saveUrlRewriteRulesMock = vi.mocked(settingsAdapter.saveUrlRewriteRules)
const checkLatestVersionMock = vi.mocked(settingsAdapter.checkLatestVersion)
beforeEach(() => {
vi.clearAllMocks()
resetStore()
})
it('hydrates settings from real config fields', async () => {
const config: IConfig = {
picBed: {
uploader: 'smms',
current: 'smms',
proxy: 'http://127.0.0.1:7890'
},
settings: {
language: 'zh-CN',
autoStart: true,
shortKey: {
'picgo:upload': {
key: 'CommandOrControl+Shift+P',
name: 'upload',
label: 'Quick Upload',
enable: true
}
},
urlRewrite: {
rules: [
{
match: 'foo',
replace: 'bar',
enable: false,
global: true,
ignoreCase: true
}
]
}
},
picgoPlugins: {}
}
getAppConfigMock.mockResolvedValue(config)
getPicBedsMock.mockResolvedValue([
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: false }
])
useStore.setState({
hasHydrated: false,
hasSettingsHydrated: false,
appConfig: null
})
await appActions.hydrateAppState()
const state = useStore.getState().appConfig
expect(state?.settings.language).toBe('zh-CN')
expect(state?.settings.autoStart).toBe(true)
expect(state?.picBed.proxy).toBe('http://127.0.0.1:7890')
expect(state?.settings.shortKey).toEqual({
'picgo:upload': {
name: 'upload',
label: 'Quick Upload',
key: 'CommandOrControl+Shift+P',
enable: true
}
})
expect(state?.settings.urlRewrite).toEqual({
rules: [
{
match: 'foo',
replace: 'bar',
enable: false,
global: true,
ignoreCase: true
}
]
})
})
it('persists prefixed settings keys without duplicating the settings prefix', async () => {
await settingsStoreActions.saveSettingsConfig('settings.language', 'zh-CN')
expect(savePatchMock).toHaveBeenCalledWith({
'settings.language': 'zh-CN'
})
expect(useStore.getState().appConfig?.settings.language).toBe('zh-CN')
})
it('persists proxy and visible pic beds with production config paths', async () => {
const nextPicBedList = [
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: true }
]
updateVisiblePicBedsMock.mockResolvedValue(nextPicBedList)
await settingsStoreActions.saveSettingsConfig({
npmProxy: 'http://127.0.0.1:7891',
npmRegistry: 'https://registry.npmmirror.com'
})
expect(savePatchMock).toHaveBeenCalledWith({
'settings.npmProxy': 'http://127.0.0.1:7891',
'settings.npmRegistry': 'https://registry.npmmirror.com'
})
expect(useStore.getState().appConfig?.settings.npmProxy).toBe(
'http://127.0.0.1:7891'
)
await settingsStoreActions.savePicBedProxy('http://127.0.0.1:7890')
expect(saveAppConfigMock).toHaveBeenCalledWith({
'picBed.proxy': 'http://127.0.0.1:7890'
})
expect(useStore.getState().appConfig?.picBed.proxy).toBe('http://127.0.0.1:7890')
await settingsStoreActions.saveVisiblePicBedNames(['SM.MS', 'GitHub'])
expect(updateVisiblePicBedsMock).toHaveBeenCalledWith(['SM.MS', 'GitHub'])
expect(useStore.getState().appConfig?.picBed.list).toEqual(nextPicBedList)
})
it('updates shortcuts through main-process handlers and syncs local state', async () => {
updateShortcutMock.mockResolvedValue(true)
await settingsStoreActions.updateShortcutKeys('picgo:upload', [
'CommandOrControl',
'Shift',
'P'
])
expect(updateShortcutMock).toHaveBeenCalledWith({
enable: true,
key: 'CommandOrControl+Shift+P',
label: 'Quick Upload',
name: 'upload',
from: 'picgo'
}, 'CommandOrControl+Shift+U')
expect(useStore.getState().appConfig?.settings.shortKey['picgo:upload']?.key).toBe(
'CommandOrControl+Shift+P'
)
await settingsStoreActions.setShortcutEnabled('picgo:upload', false)
expect(toggleShortcutEnabledMock).toHaveBeenCalledWith({
enable: false,
key: 'CommandOrControl+Shift+P',
label: 'Quick Upload',
name: 'upload',
from: 'picgo'
})
expect(useStore.getState().appConfig?.settings.shortKey['picgo:upload']?.enable).toBe(false)
})
it('persists url rewrite rules and update checks via real adapters', async () => {
const nextRules: SettingsUrlRewriteRule[] = [
{
match: 'foo',
replace: 'bar',
enable: false,
global: true,
ignoreCase: true
}
]
await settingsStoreActions.saveUrlRewriteRules(nextRules)
expect(saveUrlRewriteRulesMock).toHaveBeenCalledWith([
{
match: 'foo',
replace: 'bar',
enable: false,
global: true,
ignoreCase: true
}
])
expect(useStore.getState().appConfig?.settings.urlRewrite.rules).toEqual(nextRules)
checkLatestVersionMock.mockResolvedValue('9.9.9')
const result = await settingsStoreActions.checkUpdates()
expect(result).toEqual({
currentVersion: '3.0.0',
latestVersion: '9.9.9',
hasUpdate: true
})
expect(useStore.getState().settingsVersion.latestVersion).toBe('9.9.9')
})
it('runs side effects after saving linked settings items', async () => {
await settingsStoreActions.saveSettingsConfig('settings.autoStart', true)
expect(setAutoStartMock).toHaveBeenCalledWith(true)
})
})
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type CleanupFn = ReturnType<typeof vi.fn>
describe('renderer/utils/bridge', () => {
let onCleanup: CleanupFn
let onceCleanup: CleanupFn
const bridgeApiMock = {
ipc: {
invoke: vi.fn(),
send: vi.fn(),
removeAllListeners: vi.fn(),
on: vi.fn((_channel: string, _listener: BridgeIpcListener) => {
onCleanup = vi.fn()
return onCleanup
}),
once: vi.fn((_channel: string, _listener: BridgeIpcListener) => {
onceCleanup = vi.fn()
return onceCleanup
})
},
clipboard: {
writeText: vi.fn()
},
webUtils: {
getPathForFile: vi.fn()
},
webFrame: {
setVisualZoomLevelLimits: vi.fn()
},
env: {
platform: 'darwin' as NodeJS.Platform,
isDev: false
},
i18n: {
ObjectAdapter: {
create: vi.fn()
},
I18n: {
createFromLocales: vi.fn()
}
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
onCleanup = vi.fn()
onceCleanup = vi.fn()
vi.stubGlobal('window', { bridgeApi: bridgeApiMock })
vi.stubGlobal('bridgeApi', bridgeApiMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns preload cleanup for on', async () => {
const { ipc } = await import('@/utils/bridge')
const listener = vi.fn()
const cleanup = ipc.on('demo', listener)
cleanup()
expect(bridgeApiMock.ipc.on).toHaveBeenCalledWith('demo', listener)
expect(onCleanup).toHaveBeenCalledTimes(1)
})
it('returns preload cleanup for once', async () => {
const { ipc } = await import('@/utils/bridge')
const listener = vi.fn()
const cleanup = ipc.once('demo', listener)
cleanup()
expect(bridgeApiMock.ipc.once).toHaveBeenCalledTimes(1)
expect(bridgeApiMock.ipc.once).toHaveBeenCalledWith('demo', listener)
expect(onceCleanup).toHaveBeenCalledTimes(1)
})
it('delegates removeAllListeners to preload', async () => {
const { ipc } = await import('@/utils/bridge')
ipc.removeAllListeners('demo')
expect(bridgeApiMock.ipc.removeAllListeners).toHaveBeenCalledWith('demo')
})
})
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PICGO_SAVE_CONFIG } from '#/events/constants'
describe('renderer/utils/dataSender', () => {
const bridgeApiMock = {
ipc: {
invoke: vi.fn(),
on: vi.fn(),
once: vi.fn(),
send: vi.fn(),
removeAllListeners: vi.fn()
},
clipboard: {
writeText: vi.fn()
},
webUtils: {
getPathForFile: vi.fn()
},
webFrame: {
setVisualZoomLevelLimits: vi.fn()
},
env: {
platform: 'darwin' as NodeJS.Platform,
isDev: false
},
i18n: {
ObjectAdapter: {
create: vi.fn()
},
I18n: {
createFromLocales: vi.fn()
}
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.stubGlobal('window', { bridgeApi: bridgeApiMock })
vi.stubGlobal('bridgeApi', bridgeApiMock)
bridgeApiMock.ipc.invoke.mockResolvedValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('invokes save config IPC after saveConfig', async () => {
const { saveConfig } = await import('@/utils/dataSender')
await saveConfig('settings.language', 'en')
expect(bridgeApiMock.ipc.invoke).toHaveBeenCalledWith(PICGO_SAVE_CONFIG, {
'settings.language': 'en'
})
})
})
+182
View File
@@ -0,0 +1,182 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach } from 'vitest'
import {
GET_CURRENT_LANGUAGE,
GET_LANGUAGE_LIST,
GET_PICBEDS,
PICGO_GET_CONFIG,
RPC_ACTIONS
} from '#/events/constants'
import { IRPCActionType } from '~/universal/types/enum'
type TestBridgeListener = (...args: unknown[]) => void
const bridgeListeners = new Map<string, Set<TestBridgeListener>>()
const addBridgeListener = (channel: string, listener: TestBridgeListener) => {
const listeners = bridgeListeners.get(channel) || new Set<TestBridgeListener>()
listeners.add(listener)
bridgeListeners.set(channel, listeners)
return () => {
const currentListeners = bridgeListeners.get(channel)
currentListeners?.delete(listener)
if (currentListeners?.size === 0) {
bridgeListeners.delete(channel)
}
}
}
const emitBridgeEvent = (channel: string, ...args: unknown[]) => {
const listeners = bridgeListeners.get(channel)
if (!listeners) {
return
}
Array.from(listeners).forEach((listener) => {
listener(...args)
})
}
const createDefaultBridgeApi = (): BridgeApi => {
return {
ipc: {
send: (channel: string, ...args: unknown[]) => {
if (channel === PICGO_GET_CONFIG) {
emitBridgeEvent(PICGO_GET_CONFIG, undefined, args[1])
return
}
if (channel === GET_PICBEDS) {
emitBridgeEvent(GET_PICBEDS, [])
return
}
if (channel === GET_CURRENT_LANGUAGE) {
emitBridgeEvent(GET_CURRENT_LANGUAGE, 'en')
return
}
if (channel === GET_LANGUAGE_LIST) {
emitBridgeEvent(GET_LANGUAGE_LIST, [])
}
},
invoke: async <T>(channel: string, ...args: unknown[]) => {
if (channel === RPC_ACTIONS) {
const action = args[0]
if (action === IRPCActionType.GET_WINDOW_STATE) {
return {
success: true,
data: {
isMaximized: false
},
error: ''
} as T
}
return {
success: true,
data: null,
error: ''
} as T
}
return undefined as T
},
on: (channel: string, listener: BridgeIpcListener) => addBridgeListener(channel, listener),
once: (channel: string, listener: BridgeIpcListener) => {
let cleanup = () => {}
const wrappedListener: TestBridgeListener = (...args: unknown[]) => {
cleanup()
listener(...args)
}
cleanup = addBridgeListener(channel, wrappedListener)
return cleanup
},
removeAllListeners: (channel: string) => {
bridgeListeners.delete(channel)
}
},
webUtils: {
getPathForFile: () => '/tmp/mock-file.png'
},
webFrame: {
setVisualZoomLevelLimits: () => {}
},
env: {
platform: 'darwin',
isDev: false
},
i18n: {
ObjectAdapter: {
create: (locales: Record<string, ILocales>) => ({
getLocale: (language: string) => locales[language],
setLocales: () => {},
setLocale: () => {}
})
},
I18n: {
createFromLocales: (locales: Record<string, ILocales>, defaultLanguage: string) => {
let language = defaultLanguage
return {
getLanguage: () => language,
setLanguage: (nextLanguage: string) => {
language = nextLanguage
},
setDefaultLanguage: (nextLanguage: string) => {
language = nextLanguage
},
translate: (key: ILocalesKey, args: IStringKeyMap = {}) => {
const locale = locales[language]
const template = locale?.[key] || key
return Object.keys(args).reduce((result, token) => {
return result.replaceAll(`\${${token}}`, String(args[token]))
}, template)
}
}
}
}
}
}
}
const installDefaultBridgeApi = () => {
const bridgeApi = createDefaultBridgeApi()
;(globalThis as typeof globalThis & { bridgeApi?: BridgeApi }).bridgeApi = bridgeApi
if (typeof window !== 'undefined') {
window.bridgeApi = bridgeApi
}
}
installDefaultBridgeApi()
beforeEach(() => {
bridgeListeners.clear()
installDefaultBridgeApi()
})
afterEach(() => {
bridgeListeners.clear()
cleanup()
})
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
})
})
}
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from 'electron'
import uploader from 'apis/app/uploader'
import picgo from '@core/picgo'
import { GalleryDB } from '~/main/apis/core/datastore'
import { AlbumDB } from '~/main/apis/core/datastore'
import windowManager from 'apis/app/window/windowManager'
import { IPasteStyle, IWindowList } from '#/types/enum'
import pasteTemplate from '~/main/utils/pasteTemplate'
@@ -66,7 +66,7 @@ export function createContextMenu () {
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
@@ -235,7 +235,7 @@ export function createTray () {
// icon: files[i]
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
await AlbumDB.getInstance().insert(imgs[i])
}
handleCopyUrl(pasteText.join('\n'))
trayWindow.webContents.send('dragFiles', imgs)
+25 -16
View File
@@ -1,11 +1,9 @@
import {
WebContents
} from 'electron'
import type { WebContents } from 'electron'
import windowManager from 'apis/app/window/windowManager'
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
import uploader from '.'
import pasteTemplate from '~/main/utils/pasteTemplate'
import { GalleryDB } from '~/main/apis/core/datastore'
import { AlbumDB } from '~/main/apis/core/datastore'
import { handleCopyUrl, handleUrlEncodeWithSetting, showNotification } from '~/main/utils/common'
import { T } from '~/main/i18n/index'
import logger from '@core/picgo/logger'
@@ -21,7 +19,7 @@ const handleClipboardUploading = async (): Promise<false | ImgInfo[]> => {
return await uploader.setWebContents(win!.webContents).upload()
}
export const uploadClipboardFiles = async (): Promise<string> => {
export const uploadClipboardFilesWithInfo = async (): Promise<ImgInfo[]> => {
logger.info('upload clipboard file')
const img = await handleClipboardUploading()
if (img !== false) {
@@ -36,30 +34,34 @@ export const uploadClipboardFiles = async (): Promise<string> => {
// icon: img[0].imgUrl
})
}, 100)
await GalleryDB.getInstance().insert(img[0])
await AlbumDB.getInstance().insert(img[0])
// trayWindow just be created in mac/windows, not in linux
trayWindow?.webContents?.send('clipboardFiles', [])
trayWindow?.webContents?.send('uploadFiles', img)
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_GALLERY)
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
}
return handleUrlEncodeWithSetting(img[0].imgUrl as string)
return img
} else {
showNotification({
title: T('UPLOAD_FAILED'),
body: T('TIPS_UPLOAD_NOT_PICTURES')
})
return ''
return []
}
} else {
return ''
return []
}
}
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[]): Promise<string[]> => {
export const uploadClipboardFiles = async (): Promise<string> => {
const img = await uploadClipboardFilesWithInfo()
return img[0]?.imgUrl ? handleUrlEncodeWithSetting(img[0].imgUrl) : ''
}
export const uploadSelectedFilesWithInfo = async (webContents: WebContents, files: IFileWithPath[]): Promise<ImgInfo[]> => {
const input = files.map(item => item.path)
const imgs = await uploader.setWebContents(webContents).upload(input)
const result = []
if (imgs !== false) {
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
const pasteText: string[] = []
@@ -72,17 +74,24 @@ export const uploadSelectedFiles = async (webContents: WebContents, files: IFile
// icon: files[i].path
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
result.push(handleUrlEncodeWithSetting(imgs[i].imgUrl!))
await AlbumDB.getInstance().insert(imgs[i])
}
handleCopyUrl(pasteText.join('\n'))
// trayWindow just be created in mac/windows, not in linux
windowManager.get(IWindowList.TRAY_WINDOW)?.webContents?.send('uploadFiles', imgs)
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_GALLERY)
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
}
return result
return imgs
} else {
return []
}
}
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[]): Promise<string[]> => {
const imgs = await uploadSelectedFilesWithInfo(webContents, files)
return imgs
.map(item => item.imgUrl)
.filter((url): url is string => typeof url === 'string' && url !== '')
.map(url => handleUrlEncodeWithSetting(url))
}
-2
View File
@@ -19,7 +19,6 @@ import path from 'path'
import { privacyManager } from '~/main/utils/privacyManager'
import writeFile from 'write-file-atomic'
import { CLIPBOARD_IMAGE_FOLDER } from '~/universal/utils/static'
import { cleanupFormUploaderFiles } from '~/main/utils/cleanupFormUploaderFiles'
import { IpcMainEvent } from 'electron/main'
import { dataReportManager } from '~/main/utils/dataReport'
@@ -161,7 +160,6 @@ class Uploader {
return false
} finally {
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
cleanupFormUploaderFiles(img)
}
}
}
+5 -5
View File
@@ -1,11 +1,11 @@
import { buildRendererUrl } from '~/main/utils/env'
export const TRAY_WINDOW_URL = buildRendererUrl()
export const TRAY_WINDOW_URL = buildRendererUrl('tray')
export const SETTING_WINDOW_URL = buildRendererUrl('main-page/upload')
export const SETTING_WINDOW_URL = buildRendererUrl('main/dashboard')
export const MINI_WINDOW_URL = buildRendererUrl('mini-page')
export const MINI_WINDOW_URL = buildRendererUrl('mini')
export const RENAME_WINDOW_URL = buildRendererUrl('rename-page')
export const RENAME_WINDOW_URL = buildRendererUrl('rename')
export const TOOLBOX_WINDOW_URL = buildRendererUrl('toolbox-page')
export const TOOLBOX_WINDOW_URL = buildRendererUrl('toolbox')
+56 -23
View File
@@ -1,4 +1,4 @@
// import path from 'path'
import path from 'path'
import {
SETTING_WINDOW_URL,
TRAY_WINDOW_URL,
@@ -9,21 +9,23 @@ import {
import { IStartupMode, IWindowList } from '#/types/enum'
import bus from '@core/bus'
import { CREATE_APP_MENU } from '@core/bus/constants'
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '#/events/constants'
import { TOGGLE_SHORTKEY_MODIFIED_MODE, WINDOW_STATE_CHANGED } from '#/events/constants'
import { app } from 'electron'
import { T } from '~/main/i18n'
import { isLinux } from '~/universal/utils/common'
import { isLinux, isWindows } from '~/universal/utils/common'
import { getStaticPath } from '#/utils/staticPath'
import picgo from '@core/picgo'
import { getMainWindowState, saveMainWindowState } from './windowState'
import { isDev } from '~/main/utils/env'
// import { URLSearchParams } from 'url'
const windowList = new Map<IWindowList, IWindowListItem>()
const defaultWebPreferences = {
// preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: true,
contextIsolation: false,
nodeIntegrationInWorker: true,
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: false,
backgroundThrottling: false
}
@@ -37,7 +39,7 @@ const handleWindowParams = (windowURL: string) => {
}
export const isWindowShouldShowOnStartup = (currentWindow: IWindowList) => {
const startupMode = picgo.getConfig<IStartupMode | undefined>('settings.startupMode') || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : IStartupMode.HIDE)
const startupMode = picgo.getConfig<IStartupMode | undefined>('settings.startupMode') || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : isWindows ? IStartupMode.SHOW_MAIN_WINDOW : IStartupMode.HIDE)
switch (currentWindow) {
case IWindowList.MINI_WINDOW: {
return startupMode === IStartupMode.SHOW_MINI_WINDOW
@@ -62,8 +64,8 @@ windowList.set(IWindowList.TRAY_WINDOW, {
frame: false,
fullscreenable: false,
resizable: false,
transparent: true,
vibrancy: 'ultra-dark',
transparent: false,
backgroundColor: '#111827',
webPreferences: {
...defaultWebPreferences,
webSecurity: false
@@ -83,18 +85,21 @@ windowList.set(IWindowList.SETTING_WINDOW, {
multiple: false,
options () {
const showDockIcon = picgo.getConfig<boolean>('settings.showDockIcon') !== false
const mainWindowState = getMainWindowState()
const options: IBrowserWindowOptions = {
height: 450,
width: 800,
height: mainWindowState.height,
width: mainWindowState.width,
minHeight: 450,
minWidth: 800,
show: false,
frame: true,
frame: false,
center: true,
fullscreenable: false,
resizable: false,
resizable: true,
title: 'PicGo',
transparent: true,
transparent: false,
backgroundColor: '#0f172a',
skipTaskbar: !showDockIcon,
titleBarStyle: 'hidden',
webPreferences: {
...defaultWebPreferences,
webSecurity: false
@@ -110,6 +115,30 @@ windowList.set(IWindowList.SETTING_WINDOW, {
},
callback (window, windowManager) {
window.loadURL(handleWindowParams(SETTING_WINDOW_URL))
if (isDev) {
window.webContents.openDevTools({ mode: 'detach' })
}
window.on('maximize', () => {
window.webContents.send(WINDOW_STATE_CHANGED, {
isMaximized: true
})
})
window.on('unmaximize', () => {
window.webContents.send(WINDOW_STATE_CHANGED, {
isMaximized: false
})
})
window.on('close', () => {
const nextBounds = window.isMaximized()
? window.getNormalBounds()
: window.getBounds()
saveMainWindowState({
width: nextBounds.width,
height: nextBounds.height,
isMaximized: window.isMaximized()
})
})
window.on('closed', () => {
bus.emit(TOGGLE_SHORTKEY_MODIFIED_MODE, false)
if (process.platform === 'linux') {
@@ -118,6 +147,9 @@ windowList.set(IWindowList.SETTING_WINDOW, {
})
}
})
if (getMainWindowState().isMaximized) {
window.maximize()
}
bus.emit(CREATE_APP_MENU)
windowManager.create(IWindowList.MINI_WINDOW)
}
@@ -135,7 +167,8 @@ windowList.set(IWindowList.MINI_WINDOW, {
fullscreenable: false,
skipTaskbar: true,
resizable: false,
transparent: process.platform !== 'linux',
transparent: false,
backgroundColor: '#0f172a',
icon: getStaticPath('logo.png'),
webPreferences: {
...defaultWebPreferences
@@ -162,7 +195,7 @@ windowList.set(IWindowList.RENAME_WINDOW, {
show: true,
fullscreenable: false,
resizable: false,
backgroundColor: 'rgba(26,40,42,0.9)',
backgroundColor: '#0f172a',
webPreferences: {
...defaultWebPreferences
}
@@ -197,14 +230,14 @@ windowList.set(IWindowList.TOOLBOX_WINDOW, {
multiple: false,
options () {
const options: IBrowserWindowOptions = {
height: 450,
height: 480,
width: 800,
show: false,
frame: true,
center: true,
fullscreenable: false,
resizable: false,
backgroundColor: 'rgba(26,40,42,0.9)',
backgroundColor: '#0f172a',
title: `PicGo-${T('TOOLBOX')}`,
icon: getStaticPath('logo.png'),
webPreferences: {
@@ -223,13 +256,13 @@ windowList.set(IWindowList.TOOLBOX_WINDOW, {
if (currentWindow && currentWindow.isVisible()) {
// bounds: { x: 821, y: 75, width: 800, height: 450 }
const bounds = currentWindow.getBounds()
const positionX = bounds.x + bounds.width / 2 - 400
const positionX = Math.round(bounds.x + bounds.width / 2 - 400)
let positionY
// if is the settingWindow
if (bounds.height > 400) {
positionY = bounds.y + bounds.height / 2 - 225
positionY = Math.round(bounds.y + bounds.height / 2 - 240)
} else { // if is the miniWindow
positionY = bounds.y + bounds.height / 2
positionY = Math.round(bounds.y + bounds.height / 2)
}
window.setPosition(positionX, positionY, false)
}
+91
View File
@@ -0,0 +1,91 @@
import fs from 'fs-extra'
import path from 'path'
import { STORE_PATH } from '~/main/utils/env'
interface IMainWindowState {
width: number
height: number
isMaximized: boolean
}
const DEFAULT_MAIN_WINDOW_STATE: IMainWindowState = {
width: 800,
height: 450,
isMaximized: false
}
interface IWindowStateStorage {
mainWindow: IMainWindowState
}
const WINDOW_STATE_PATH = path.join(STORE_PATH, 'window-state.json')
function normalizeMainWindowState (value: unknown): IMainWindowState {
if (!value || typeof value !== 'object') {
return DEFAULT_MAIN_WINDOW_STATE
}
const {
width,
height,
isMaximized
} = value as Partial<IMainWindowState>
return {
width: typeof width === 'number' && width >= DEFAULT_MAIN_WINDOW_STATE.width
? width
: DEFAULT_MAIN_WINDOW_STATE.width,
height: typeof height === 'number' && height >= DEFAULT_MAIN_WINDOW_STATE.height
? height
: DEFAULT_MAIN_WINDOW_STATE.height,
isMaximized: isMaximized === true
}
}
function normalizeWindowStateStorage (value: unknown): IWindowStateStorage {
if (!value || typeof value !== 'object') {
return {
mainWindow: DEFAULT_MAIN_WINDOW_STATE
}
}
const mainWindow = 'mainWindow' in value
? (value as { mainWindow?: unknown }).mainWindow
: undefined
return {
mainWindow: normalizeMainWindowState(mainWindow)
}
}
function readWindowStateStorage (): IWindowStateStorage {
try {
if (fs.existsSync(WINDOW_STATE_PATH)) {
const raw = fs.readFileSync(WINDOW_STATE_PATH, 'utf8')
return normalizeWindowStateStorage(JSON.parse(raw))
}
} catch (error) {
console.error(error)
}
return {
mainWindow: DEFAULT_MAIN_WINDOW_STATE
}
}
export function getMainWindowState (): IMainWindowState {
return readWindowStateStorage().mainWindow
}
export function saveMainWindowState (state: IMainWindowState): void {
try {
const currentState = readWindowStateStorage()
fs.writeJsonSync(WINDOW_STATE_PATH, {
...currentState,
mainWindow: normalizeMainWindowState(state)
})
} catch (error) {
console.error(error)
}
}
+3 -3
View File
@@ -24,7 +24,7 @@ function dbChecker () {
if (process.type !== 'renderer') {
// db save bak
try {
const { dbPath, dbBackupPath } = getGalleryDBPath()
const { dbPath, dbBackupPath } = getAlbumDBPath()
if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, dbBackupPath)
}
@@ -118,7 +118,7 @@ function dbPathDir () {
return path.dirname(dbPathChecker())
}
function getGalleryDBPath (): {
function getAlbumDBPath (): {
dbPath: string
dbBackupPath: string
} {
@@ -144,6 +144,6 @@ export {
dbChecker,
dbPathChecker,
dbPathDir,
getGalleryDBPath,
getAlbumDBPath,
getFormImageFolderPath
}
+8 -7
View File
@@ -1,23 +1,24 @@
import path from 'path'
import fs from 'fs-extra'
import { DBStore } from '@picgo/store'
import { getGalleryDBPath } from './dbChecker'
import { getAlbumDBPath } from './dbChecker'
const DB_PATH: string = getGalleryDBPath().dbPath
const DB_PATH: string = getAlbumDBPath().dbPath
fs.ensureDirSync(path.dirname(DB_PATH))
class GalleryDB {
class AlbumDB {
private static instance: DBStore
private constructor () {}
public static getInstance (): DBStore {
if (!GalleryDB.instance) {
GalleryDB.instance = new DBStore(DB_PATH, 'gallery')
if (!AlbumDB.instance) {
// Keep the namespace 'gallery' for @picgo/store to preserve existing user data on disk.
AlbumDB.instance = new DBStore(DB_PATH, 'gallery')
}
return GalleryDB.instance
return AlbumDB.instance
}
}
export {
GalleryDB
AlbumDB
}
+5
View File
@@ -1,4 +1,5 @@
import { dbChecker, dbPathChecker } from 'apis/core/datastore/dbChecker'
import { shell } from 'electron'
import pkg from 'root/package.json'
import { PicGo } from 'picgo'
@@ -15,6 +16,10 @@ picgo.saveConfig({
global.PICGO_GUI_VERSION = pkg.version
picgo.GUI_VERSION = global.PICGO_GUI_VERSION
picgo.openUrl = (url: string) => {
return shell.openExternal(url)
}
// const originPicGoSaveConfig = picgo.saveConfig.bind(picgo)
// function flushDB () {
+19 -12
View File
@@ -4,8 +4,8 @@ import {
ipcMain
} from 'electron'
import picgo from '@core/picgo'
import { GalleryDB } from 'apis/core/datastore'
import { dbPathChecker, defaultConfigPath, getGalleryDBPath } from 'apis/core/datastore/dbChecker'
import { AlbumDB } from 'apis/core/datastore'
import { dbPathChecker, defaultConfigPath, getAlbumDBPath } from 'apis/core/datastore/dbChecker'
import uploader from 'apis/app/uploader'
import pasteTemplate from '~/main/utils/pasteTemplate'
import { handleCopyUrl, showNotification as showMainNotification } from '~/main/utils/common'
@@ -88,11 +88,11 @@ class GuiApi implements IGuiApi {
// icon: imgs[i].imgUrl
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
await AlbumDB.getInstance().insert(imgs[i])
}
handleCopyUrl(pasteText.join('\n'))
webContents?.send('uploadFiles', imgs)
webContents?.send(IRPCActionType.UPDATE_GALLERY)
webContents?.send(IRPCActionType.UPDATE_ALBUM)
return imgs
}
return []
@@ -144,25 +144,27 @@ class GuiApi implements IGuiApi {
*/
async getConfigPath () {
const currentConfigPath = dbPathChecker()
const galleryDBPath = getGalleryDBPath().dbPath
const albumDBPath = getAlbumDBPath().dbPath
return {
defaultConfigPath,
currentConfigPath,
galleryDBPath
albumDBPath,
/** @deprecated Use `albumDBPath` instead. */
galleryDBPath: albumDBPath
}
}
get galleryDB (): DBStore {
return new Proxy<DBStore>(GalleryDB.getInstance(), {
get albumDB (): DBStore {
return new Proxy<DBStore>(AlbumDB.getInstance(), {
get (target, prop: keyof DBStore) {
if (prop === 'overwrite') {
return new Proxy(GalleryDB.getInstance().overwrite, {
return new Proxy(AlbumDB.getInstance().overwrite, {
apply (target, ctx, args) {
return new Promise((resolve) => {
const guiApi = GuiApi.getInstance()
guiApi.showMessageBox({
title: T('TIPS_WARNING'),
message: T('TIPS_PLUGIN_REMOVE_GALLERY_ITEM'),
message: T('TIPS_PLUGIN_REMOVE_ALBUM_ITEM'),
type: 'info',
buttons: ['Yes', 'No']
}).then(res => {
@@ -177,13 +179,13 @@ class GuiApi implements IGuiApi {
})
}
if (prop === 'removeById') {
return new Proxy(GalleryDB.getInstance().removeById, {
return new Proxy(AlbumDB.getInstance().removeById, {
apply (target, ctx, args) {
return new Promise((resolve) => {
const guiApi = GuiApi.getInstance()
guiApi.showMessageBox({
title: T('TIPS_WARNING'),
message: T('TIPS_PLUGIN_REMOVE_GALLERY_ITEM'),
message: T('TIPS_PLUGIN_REMOVE_ALBUM_ITEM'),
type: 'info',
buttons: ['Yes', 'No']
}).then(res => {
@@ -201,6 +203,11 @@ class GuiApi implements IGuiApi {
}
})
}
/** @deprecated Use `albumDB` instead. */
get galleryDB (): DBStore {
return this.albumDB
}
}
export default GuiApi
+22 -3
View File
@@ -11,7 +11,7 @@ import uploader from 'apis/app/uploader'
import pasteTemplate from '~/main/utils/pasteTemplate'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import { GalleryDB } from '~/main/apis/core/datastore'
import { AlbumDB } from '~/main/apis/core/datastore'
import server from '~/main/server'
import getPicBeds from '~/main/utils/getPicBeds'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
@@ -21,9 +21,12 @@ import {
OPEN_DEVTOOLS,
SHOW_MINI_PAGE_MENU,
MINIMIZE_WINDOW,
MAXIMIZE_WINDOW,
CLOSE_WINDOW,
WINDOW_STATE_CHANGED,
SHOW_MAIN_PAGE_MENU,
SHOW_UPLOAD_PAGE_MENU,
SHOW_PRIVACY_MESSAGE,
OPEN_USER_STORE_FILE,
OPEN_URL,
SHOW_PLUGIN_PAGE_MENU,
@@ -41,6 +44,7 @@ import { buildMainPageMenu, buildMiniPageMenu, buildPluginPageMenu, buildPicBedL
import path from 'path'
import { T } from '~/main/i18n'
import { STORE_PATH } from '~/main/utils/env'
import { privacyManager } from '~/main/utils/privacyManager'
export default {
listen () {
@@ -59,10 +63,10 @@ export default {
// icon: file[0]
// icon: img[0].imgUrl
})
await GalleryDB.getInstance().insert(img[0])
await AlbumDB.getInstance().insert(img[0])
trayWindow.webContents.send('clipboardFiles', [])
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(IRPCActionType.UPDATE_GALLERY)
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(IRPCActionType.UPDATE_ALBUM)
}
}
trayWindow.webContents.send('uploadFiles')
@@ -181,6 +185,9 @@ export default {
window
})
})
ipcMain.on(SHOW_PRIVACY_MESSAGE, () => {
privacyManager.show(false)
})
ipcMain.on(SHOW_UPLOAD_PAGE_MENU, () => {
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
const menu = buildPicBedListMenu()
@@ -199,6 +206,18 @@ export default {
const window = BrowserWindow.getFocusedWindow()
window?.minimize()
})
ipcMain.on(MAXIMIZE_WINDOW, () => {
const window = BrowserWindow.getFocusedWindow()
if (!window) return
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
window.webContents.send(WINDOW_STATE_CHANGED, {
isMaximized: window.isMaximized()
})
})
ipcMain.on(CLOSE_WINDOW, () => {
const window = BrowserWindow.getFocusedWindow()
if (process.platform === 'linux') {
+69 -157
View File
@@ -1,21 +1,20 @@
import path from 'path'
import GuiApi from 'apis/gui'
import {
dialog,
shell,
IpcMainEvent,
ipcMain,
clipboard
} from 'electron'
import fs from 'fs-extra'
import { IPasteStyle, IPicGoHelperType, IWindowList } from '#/types/enum'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
import { IPasteStyle, IPicGoHelperType, IRPCActionType, IWindowList } from '#/types/enum'
import picgo from '@core/picgo'
import { handleStreamlinePluginName, simpleClone } from '~/universal/utils/common'
import { IGuiMenuItem, PicGo as PicGoCore } from 'picgo'
import { IBuildInEvent, IGuiMenuItem, PicGo as PicGoCore, evaluatePluginConfig } from 'picgo'
import windowManager from 'apis/app/window/windowManager'
import { showNotification } from '~/main/utils/common'
import { dbPathChecker } from 'apis/core/datastore/dbChecker'
import logger from 'apis/core/picgo/logger'
import {
PICGO_SAVE_CONFIG,
PICGO_GET_CONFIG,
@@ -34,19 +33,15 @@ import {
GET_PICBED_CONFIG
} from '#/events/constants'
import { GalleryDB } from 'apis/core/datastore'
import { AlbumDB } from 'apis/core/datastore'
import { IObject, IFilter } from '@picgo/store/dist/types'
import pasteTemplate from '../utils/pasteTemplate'
import { i18nManager, T } from '~/main/i18n'
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
import { rpcServer } from './rpc'
const STORE_PATH = path.dirname(dbPathChecker())
interface GuiMenuItem {
label: string
handle: (arg0: PicGoCore, arg1: GuiApi) => Promise<void>
}
// get uploader or transformer config
const getConfig = (name: string, type: IPicGoHelperType, ctx: PicGoCore) => {
let config: any[] = []
@@ -64,15 +59,12 @@ const getConfig = (name: string, type: IPicGoHelperType, ctx: PicGoCore) => {
}
const handleConfigWithFunction = (config: any[]) => {
for (const i in config) {
if (typeof config[i].default === 'function') {
config[i].default = config[i].default()
return evaluatePluginConfig(config, {}, {
onError: (fieldName, kind, error) => {
const message = error instanceof Error ? error.message : String(error)
picgo.log.warn(`[plugin-config] ${fieldName}.${kind} threw: ${message}`)
}
if (typeof config[i].choices === 'function') {
config[i].choices = config[i].choices()
}
}
return config
})
}
const getPluginList = (): IPicGoPlugin[] => {
@@ -147,79 +139,6 @@ const handleGetPluginList = () => {
})
}
const handlePluginInstall = () => {
ipcMain.on('installPlugin', async (event: IpcMainEvent, fullName: string) => {
const dispose = handleNPMError()
const res = await picgo.pluginHandler.install([fullName])
event.sender.send('installPlugin', {
success: res.success,
body: fullName,
errMsg: res.success ? '' : res.body
})
if (res.success) {
shortKeyHandler.registerPluginShortKey(res.body[0])
} else {
showNotification({
title: T('PLUGIN_INSTALL_FAILED'),
body: res.body as string
})
}
event.sender.send('hideLoading')
dispose()
})
}
const handlePluginUninstall = async (fullName: string) => {
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
const dispose = handleNPMError()
const res = await picgo.pluginHandler.uninstall([fullName])
if (res.success) {
window.webContents.send('uninstallSuccess', res.body[0])
shortKeyHandler.unregisterPluginShortKey(res.body[0])
} else {
showNotification({
title: T('PLUGIN_UNINSTALL_FAILED'),
body: res.body as string
})
}
window.webContents.send('hideLoading')
dispose()
}
const handlePluginUpdate = async (fullName: string) => {
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
const dispose = handleNPMError()
const res = await picgo.pluginHandler.update([fullName])
if (res.success) {
window.webContents.send('updateSuccess', res.body[0])
} else {
showNotification({
title: T('PLUGIN_UPDATE_FAILED'),
body: res.body as string
})
}
window.webContents.send('hideLoading')
dispose()
}
const handleNPMError = (): IDispose => {
const handler = (msg: string) => {
if (msg === 'NPM is not installed') {
dialog.showMessageBox({
title: T('TIPS_ERROR'),
message: T('TIPS_INSTALL_NODE_AND_RELOAD_PICGO'),
buttons: ['Yes']
}).then((res) => {
if (res.response === 0) {
shell.openExternal('https://nodejs.org/')
}
})
}
}
picgo.once('failed', handler)
return () => picgo.off('failed', handler)
}
const handleGetPicBedConfig = () => {
ipcMain.on(GET_PICBED_CONFIG, (event: IpcMainEvent, type: string) => {
const name = picgo.helper.uploader.get(type)?.name || type
@@ -233,21 +152,6 @@ const handleGetPicBedConfig = () => {
})
}
// TODO: remove it
const handlePluginActions = () => {
ipcMain.on('pluginActions', (event: IpcMainEvent, name: string, label: string) => {
const plugin = picgo.pluginLoader.getPlugin(name)
if (plugin?.guiMenu?.(picgo)?.length) {
const menu: GuiMenuItem[] = plugin.guiMenu(picgo)
menu.forEach(item => {
if (item.label === label) {
item.handle(picgo, GuiApi.getInstance())
}
})
}
})
}
const handleRemoveFiles = () => {
ipcMain.on('removeFiles', (event: IpcMainEvent, files: ImgInfo[]) => {
setTimeout(() => {
@@ -259,6 +163,7 @@ const handleRemoveFiles = () => {
const handlePicGoSaveConfig = () => {
ipcMain.handle(PICGO_SAVE_CONFIG, (_event, data: IObj) => {
picgo.saveConfig(data)
notifyAppConfigUpdated()
return true
})
}
@@ -270,74 +175,39 @@ const handlePicGoGetConfig = () => {
})
}
const handleImportLocalPlugin = () => {
ipcMain.on('importLocalPlugin', async (event: IpcMainEvent) => {
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)!
const res = await dialog.showOpenDialog(settingWindow, {
properties: ['openDirectory']
})
const filePaths = res.filePaths
if (filePaths.length > 0) {
const res = await picgo.pluginHandler.install(filePaths)
if (res.success) {
try {
const list = simpleClone(getPluginList())
event.sender.send('pluginList', list)
} catch (e: any) {
event.sender.send('pluginList', [])
showNotification({
title: T('TIPS_GET_PLUGIN_LIST_FAILED'),
body: e.message
})
}
showNotification({
title: T('PLUGIN_IMPORT_SUCCEED'),
body: ''
})
} else {
showNotification({
title: T('PLUGIN_IMPORT_FAILED'),
body: res.body as string
})
}
}
event.sender.send('hideLoading')
})
}
const handlePicGoGalleryDB = () => {
const handlePicGoAlbumDB = () => {
ipcMain.on(PICGO_GET_DB, async (event: IpcMainEvent, filter: IFilter, callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.get(filter)
event.sender.send(PICGO_GET_DB, res, callbackId)
})
ipcMain.on(PICGO_INSERT_DB, async (event: IpcMainEvent, value: IObject, callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.insert(value)
event.sender.send(PICGO_INSERT_DB, res, callbackId)
})
ipcMain.on(PICGO_INSERT_MANY_DB, async (event: IpcMainEvent, value: IObject[], callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.insertMany(value)
event.sender.send(PICGO_INSERT_MANY_DB, res, callbackId)
})
ipcMain.on(PICGO_UPDATE_BY_ID_DB, async (event: IpcMainEvent, id: string, value: IObject[], callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.updateById(id, value)
event.sender.send(PICGO_UPDATE_BY_ID_DB, res, callbackId)
})
ipcMain.on(PICGO_GET_BY_ID_DB, async (event: IpcMainEvent, id: string, callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.getById(id)
event.sender.send(PICGO_GET_BY_ID_DB, res, callbackId)
})
ipcMain.on(PICGO_REMOVE_BY_ID_DB, async (event: IpcMainEvent, id: string, callbackId: string) => {
const dbStore = GalleryDB.getInstance()
const dbStore = AlbumDB.getInstance()
const res = await dbStore.removeById(id)
event.sender.send(PICGO_REMOVE_BY_ID_DB, res, callbackId)
})
@@ -387,7 +257,7 @@ const handleI18n = () => {
const miniWindow = windowManager.get(IWindowList.MINI_WINDOW)
miniWindow?.webContents.send(SET_CURRENT_LANGUAGE, lang, locales)
}
// event.sender.send(SET_CURRENT_LANGUAGE, lang, locales)
notifyAppConfigUpdated()
})
ipcMain.on(GET_CURRENT_LANGUAGE, (event: IpcMainEvent) => {
const { lang, locales } = i18nManager.getCurrentLocales()
@@ -395,6 +265,53 @@ const handleI18n = () => {
})
}
const PICGO_CLOUD_TYPE = 'picgo-cloud'
const markImportedItemsInAlbumDB = async (items: Array<{ id?: string, type?: string }>): Promise<void> => {
const dbStore = AlbumDB.getInstance()
const itemsToMark = items.filter(
(item) => typeof item.id === 'string' && item.id.trim() !== '' && item.type !== PICGO_CLOUD_TYPE
)
for (const item of itemsToMark) {
try {
await dbStore.updateById(item.id!, { _importToPicGoCloud: true })
} catch {
// Silently skip items that don't exist in local DB
}
}
}
const handleCloudAlbumEvents = () => {
picgo.on(IBuildInEvent.CLOUD_ALBUM_UPDATED, (payload: { items?: Array<{ id?: string, type?: string }> } | undefined) => {
// Broadcast to both settings and tray windows
for (const windowType of [IWindowList.SETTING_WINDOW, IWindowList.TRAY_WINDOW]) {
if (windowManager.has(windowType)) {
windowManager.get(windowType)!.webContents.send(
IRPCActionType.UPDATE_CLOUD_ALBUM
)
}
}
// Write back _importToPicGoCloud flag to local gallery DB for non-picgo-cloud items
const items = payload?.items
if (Array.isArray(items) && items.length > 0) {
markImportedItemsInAlbumDB(items).catch((error) => {
logger.error('[PicGo Cloud][album] failed to mark imported items in album DB', error)
})
}
})
picgo.on(IBuildInEvent.CLOUD_IMPORT_PROGRESS, (progress: unknown) => {
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send(
'CLOUD_IMPORT_PROGRESS',
progress
)
}
})
}
const handleRPCActions = () => {
rpcServer.start()
}
@@ -402,20 +319,15 @@ const handleRPCActions = () => {
export default {
listen () {
handleGetPluginList()
handlePluginInstall()
handleGetPicBedConfig()
handlePluginActions()
handleRemoveFiles()
handlePicGoSaveConfig()
handlePicGoGetConfig()
handlePicGoGalleryDB()
handleImportLocalPlugin()
handlePicGoAlbumDB()
handleOpenFile()
handleOpenWindow()
handleI18n()
handleCloudAlbumEvents()
handleRPCActions()
},
// TODO: separate to single file
handlePluginUninstall,
handlePluginUpdate
}
}
+15 -50
View File
@@ -1,5 +1,5 @@
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import { IPluginMenuAction, IWindowList } from '#/types/enum'
import { Menu, BrowserWindow, app, dialog } from 'electron'
import picgo from '@core/picgo'
import {
@@ -8,8 +8,7 @@ import {
import { privacyManager } from '~/main/utils/privacyManager'
import pkg from 'root/package.json'
import GuiApi from 'apis/gui'
import { PICGO_CONFIG_PLUGIN, PICGO_HANDLE_PLUGIN_DONE, PICGO_HANDLE_PLUGIN_ING, PICGO_TOGGLE_PLUGIN, SHOW_MAIN_PAGE_DONATION, SHOW_MAIN_PAGE_QRCODE } from '~/universal/events/constants'
import picgoCoreIPC from '~/main/events/picgoCoreIPC'
import { PICGO_CONFIG_PLUGIN, PICGO_PLUGIN_MENU_ACTION, SHOW_MAIN_PAGE_DONATION, SHOW_MAIN_PAGE_QRCODE } from '~/universal/events/constants'
import { PicGo as PicGoCore } from 'picgo'
import { T } from '~/main/i18n'
import { buildPicBedListMenu } from './picBedListMenu'
@@ -49,7 +48,7 @@ const buildMiniPageMenu = () => {
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
@@ -110,7 +109,7 @@ const buildMainPageMenu = (win: BrowserWindow) => {
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
@@ -122,71 +121,38 @@ const buildMainPageMenu = (win: BrowserWindow) => {
// TODO: separate to single file
const handleRestoreState = (item: string, name: string): void => {
if (item === 'uploader') {
const current = picgo.getConfig('picBed.current')
if (current === name) {
picgo.saveConfig({
'picBed.current': 'smms',
'picBed.uploader': 'smms'
})
}
}
if (item === 'transformer') {
const current = picgo.getConfig('picBed.transformer')
if (current === name) {
picgo.saveConfig({
'picBed.transformer': 'path'
})
}
}
}
const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
const menu = [{
label: T('ENABLE_PLUGIN'),
enabled: !plugin.enabled,
click () {
picgo.saveConfig({
[`picgoPlugins.${plugin.fullName}`]: true
})
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
window.webContents.send(PICGO_TOGGLE_PLUGIN, plugin.fullName, true)
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.ENABLE)
}
}, {
label: T('DISABLE_PLUGIN'),
enabled: plugin.enabled,
click () {
picgo.saveConfig({
[`picgoPlugins.${plugin.fullName}`]: false
})
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
window.webContents.send(PICGO_TOGGLE_PLUGIN, plugin.fullName, false)
window.webContents.send(PICGO_HANDLE_PLUGIN_DONE, plugin.fullName)
if (plugin.config.transformer.name) {
handleRestoreState('transformer', plugin.config.transformer.name)
}
if (plugin.config.uploader.name) {
handleRestoreState('uploader', plugin.config.uploader.name)
}
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.DISABLE)
}
}, {
label: T('UNINSTALL_PLUGIN'),
click () {
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
picgoCoreIPC.handlePluginUninstall(plugin.fullName)
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.UNINSTALL)
}
}, {
label: T('UPDATE_PLUGIN'),
click () {
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
window.webContents.send(PICGO_HANDLE_PLUGIN_ING, plugin.fullName)
picgoCoreIPC.handlePluginUpdate(plugin.fullName)
window.webContents.send(PICGO_PLUGIN_MENU_ACTION, plugin.fullName, IPluginMenuAction.UPDATE)
}
}]
for (const i in plugin.config) {
if (i === 'uploader') {
continue
}
if (plugin.config[i].config.length > 0) {
const obj = {
label: T('CONFIG_THING', {
@@ -209,7 +175,7 @@ const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
const pluginTransformer = plugin.config.transformer.name
const obj = {
label: `${currentTransformer === pluginTransformer ? T('DISABLE') : T('ENABLE')}transformer - ${plugin.config.transformer.name}`,
label: `${currentTransformer === pluginTransformer ? T('DISABLE') : T('ENABLE')} transformer - ${plugin.config.transformer.name}`,
click () {
const transformer = plugin.config.transformer.name
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
@@ -237,10 +203,9 @@ const buildPluginPageMenu = (plugin: IPicGoPlugin) => {
menu.push({
label: i.label,
click () {
// ipcRenderer.send('pluginActions', plugin.fullName, i.label)
const picgPlugin = picgo.pluginLoader.getPlugin(plugin.fullName)
if (picgPlugin?.guiMenu?.(picgo)?.length) {
const menu: GuiMenuItem[] = picgPlugin.guiMenu(picgo)
const picgoPlugin = picgo.pluginLoader.getPlugin(plugin.fullName)
if (picgoPlugin?.guiMenu?.(picgo)?.length) {
const menu: GuiMenuItem[] = picgoPlugin.guiMenu(picgo)
menu.forEach(item => {
if (item.label === i.label) {
item.handle(picgo, GuiApi.getInstance())
+6 -3
View File
@@ -4,7 +4,6 @@ import { Menu } from 'electron'
import getPicBeds from '~/main/utils/getPicBeds'
import picgo from '@core/picgo'
import { T } from '~/main/i18n'
import { changeCurrentUploader } from '~/main/utils/handleUploaderConfig'
export const buildPicBedListMenu = () => {
const picBeds = getPicBeds()
@@ -17,7 +16,7 @@ export const buildPicBedListMenu = () => {
}, {
type: 'separator'
}]
let submenu = picBeds.filter(item => item.visible).map(item => {
let submenu = picBeds.filter(item => item.visible !== false).map(item => {
const configList = picBedConfigList?.[item.type]?.configList
const defaultId = picBedConfigList?.[item.type]?.defaultId
const hasSubmenu = !!configList
@@ -34,7 +33,11 @@ export const buildPicBedListMenu = () => {
type: 'checkbox',
checked: config._id === defaultId && (item.type === currentPicBed),
click: function () {
changeCurrentUploader(item.type, config, config._id)
try {
picgo.uploaderConfig.use(item.type, config._configName)
} catch (e) {
picgo.log.error(e instanceof Error ? e : new Error(String(e)))
}
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
}
+26 -5
View File
@@ -1,16 +1,19 @@
import { ipcMain, IpcMainEvent } from 'electron'
import { ipcMain, IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { IRPCActionType } from '~/universal/types/enum'
import { RPC_ACTIONS } from '#/events/constants'
import { configRouter } from './routes/config'
import { versionRouter } from './routes/version'
import { toolboxRouter } from './routes/toolbox'
import { systemRouter } from './routes/system'
import { galleryToolboxRouter } from './routes/galleryToolbox'
import { albumToolboxRouter } from './routes/albumToolbox'
import { cloudRouter } from './routes/cloud'
import { pluginsRouter } from './routes/plugins'
import { fail, isIRPCResult, ok } from './utils'
class RPCServer implements IRPCServer {
private routes: IRPCRoutes = new Map()
private rpcEventHandler = async (event: IpcMainEvent, action: IRPCActionType, args: any[], callbackId: string) => {
private rpcEventHandler = async (event: IpcMainEvent, action: IRPCActionType, args: any[], callbackId?: string) => {
try {
const handler = this.routes.get(action)
if (!handler) {
@@ -23,11 +26,25 @@ class RPCServer implements IRPCServer {
}
}
private rpcInvokeHandler = async (_event: IpcMainInvokeEvent, action: IRPCActionType, args: any[] = []) => {
const handler = this.routes.get(action)
if (!handler) {
return fail(new Error(`RPC action not supported: ${action}`))
}
try {
const res = await handler(args, _event)
// For invoke-based RPC, normalize the return value to IRPCResult.
return isIRPCResult(res) ? res : ok(res)
} catch (e) {
return fail(e)
}
}
/**
* if sendback data is null, then it means that the action is not supported or error occurs
* if there is no callbackId, then do not send back
*/
private sendBack (event: IpcMainEvent, action: IRPCActionType, data: any, callbackId: string) {
private sendBack (event: IpcMainEvent, action: IRPCActionType, data: any, callbackId?: string) {
if (callbackId) {
event.sender.send(RPC_ACTIONS, data, action, callbackId)
}
@@ -35,6 +52,7 @@ class RPCServer implements IRPCServer {
start () {
ipcMain.on(RPC_ACTIONS, this.rpcEventHandler)
ipcMain.handle(RPC_ACTIONS, this.rpcInvokeHandler)
}
use (routes: IRPCRoutes) {
@@ -45,6 +63,7 @@ class RPCServer implements IRPCServer {
stop () {
ipcMain.off(RPC_ACTIONS, this.rpcEventHandler)
ipcMain.removeHandler(RPC_ACTIONS)
}
}
@@ -54,7 +73,9 @@ rpcServer.use(configRouter.routes())
rpcServer.use(versionRouter.routes())
rpcServer.use(toolboxRouter.routes())
rpcServer.use(systemRouter.routes())
rpcServer.use(galleryToolboxRouter.routes())
rpcServer.use(albumToolboxRouter.routes())
rpcServer.use(cloudRouter.routes())
rpcServer.use(pluginsRouter.routes())
export {
rpcServer
@@ -38,7 +38,7 @@ function buildFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>): stri
function validateRuleOrThrow (rule: IUrlRewriteRule) {
if (!rule.match.trim() || !rule.replace.trim()) {
throw new Error(T('GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED'))
throw new Error(T('ALBUM_URL_REWRITE_TEMP_RULE_REQUIRED'))
}
try {
new RegExp(rule.match, buildFlags(rule))
@@ -57,23 +57,23 @@ function applyFirstMatchRewrite (ctx: IPicGo, imgItem: ImgInfo, rules: IUrlRewri
PicGoUtils.applyUrlRewriteToImgInfo(imgInfo, rules, {
log: {
error: (...args: Parameters<IPicGo['log']['error']>) => ctx.log.error(...args),
warn: () => ctx.log.warn(T('GALLERY_URL_REWRITE_EMPTY_RESULT_WARN'))
warn: () => ctx.log.warn(T('ALBUM_URL_REWRITE_EMPTY_RESULT_WARN'))
}
})
if (imgInfo.imgUrl === '') return imgItem
return imgInfo
}
export const galleryMenu = () => {
export const albumMenu = () => {
return [{
label: T('GALLERY_URL_REWRITE_TITLE'),
label: T('ALBUM_URL_REWRITE_TITLE'),
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
if (!selectedList.length) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_WARN_NO_SELECTION')
title: T('ALBUM_URL_REWRITE_TITLE'),
body: T('ALBUM_URL_REWRITE_WARN_NO_SELECTION')
})
logger.warn(T('GALLERY_URL_REWRITE_WARN_NO_SELECTION'))
logger.warn(T('ALBUM_URL_REWRITE_WARN_NO_SELECTION'))
return
}
@@ -81,14 +81,14 @@ export const galleryMenu = () => {
const config: IPicGoPluginConfig[] = [
{
alias: T('GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES'),
alias: T('ALBUM_URL_REWRITE_APPLY_GLOBAL_RULES'),
name: 'applyGlobalRules',
type: 'confirm',
default: globalRules.length > 0,
required: false,
confirmText: T('SETTINGS_OPEN'),
cancelText: T('SETTINGS_CLOSE'),
tips: `${T('GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
tips: `${T('ALBUM_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
},
{
alias: T('URL_REWRITE_MATCH'),
@@ -97,7 +97,7 @@ export const galleryMenu = () => {
message: T('URL_REWRITE_MATCH_PLACEHOLDER'),
default: '',
required: false,
tips: `${T('GALLERY_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
tips: `${T('ALBUM_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
},
{
alias: T('URL_REWRITE_REPLACE'),
@@ -130,7 +130,7 @@ export const galleryMenu = () => {
}
]
const options: IPicGoPluginShowConfigDialogOption = {
title: T('GALLERY_URL_REWRITE_TITLE'),
title: T('ALBUM_URL_REWRITE_TITLE'),
config
}
const res = await guiApi.showConfigDialog<IUrlRewriteDialogResult>(options)
@@ -157,7 +157,7 @@ export const galleryMenu = () => {
validateRuleOrThrow(tempRule)
} catch (e: any) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
title: T('ALBUM_URL_REWRITE_TITLE'),
body: e.message
})
return
@@ -166,8 +166,8 @@ export const galleryMenu = () => {
if (!applyGlobalRules && !tempRule) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_RULES_TO_APPLY')
title: T('ALBUM_URL_REWRITE_TITLE'),
body: T('ALBUM_URL_REWRITE_NO_RULES_TO_APPLY')
})
return
}
@@ -175,12 +175,12 @@ export const galleryMenu = () => {
let shouldSaveTempRule = false
if (tempRule) {
const saveRes = await guiApi.showMessageBox({
title: T('GALLERY_URL_REWRITE_TITLE'),
message: T('GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
title: T('ALBUM_URL_REWRITE_TITLE'),
message: T('ALBUM_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
type: 'info',
buttons: [
T('GALLERY_URL_REWRITE_APPLY_AND_SAVE'),
T('GALLERY_URL_REWRITE_APPLY_ONLY'),
T('ALBUM_URL_REWRITE_APPLY_AND_SAVE'),
T('ALBUM_URL_REWRITE_APPLY_ONLY'),
T('CANCEL')
]
})
@@ -207,8 +207,8 @@ export const galleryMenu = () => {
if (changedList.length === 0) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_CHANGES')
title: T('ALBUM_URL_REWRITE_RESULT_TITLE'),
body: T('ALBUM_URL_REWRITE_NO_CHANGES')
})
return
}
@@ -220,9 +220,9 @@ export const galleryMenu = () => {
})
}
const updateRes = await guiApi.galleryDB.updateMany(changedList)
const updateRes = await guiApi.albumDB.updateMany(changedList)
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
title: T('ALBUM_URL_REWRITE_RESULT_TITLE'),
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}`
})
}
@@ -0,0 +1,6 @@
import { albumMenu as changeURLAlbumMenu } from './changeURL'
export const builtInAlbumToolboxMenu = () => {
const menuList = [...changeURLAlbumMenu()]
return menuList
}
@@ -0,0 +1,42 @@
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
import { AlbumSource } from '#/types/cloudAlbum'
import { RPCRouter } from '../../router'
import windowManager from '~/main/apis/app/window/windowManager'
import { albumMenuListManager } from './menuListManager'
import logger from 'apis/core/picgo/logger'
const albumToolboxRouter = new RPCRouter()
albumToolboxRouter.add(IRPCActionType.GET_ALBUM_MENU_LIST, async (args) => {
const [selectedList] = args as IGetAlbumMenuListArgs
const win = windowManager.get(IWindowList.SETTING_WINDOW)!
const menu = albumMenuListManager.getMenu(selectedList)
menu.popup({
window: win
})
})
/** Broadcast album source changes to all renderer windows except the sender. */
const SYNC_TARGET_WINDOWS: IWindowList[] = [
IWindowList.SETTING_WINDOW,
IWindowList.TRAY_WINDOW,
IWindowList.MINI_WINDOW
]
albumToolboxRouter.add(IRPCActionType.SYNC_ALBUM_SOURCE, async (args, event) => {
const [source] = args as [AlbumSource]
logger.debug('[Album][syncAlbumSource]', `source=${source}`)
const senderWebContents = event.sender
for (const windowType of SYNC_TARGET_WINDOWS) {
if (!windowManager.has(windowType)) continue
const win = windowManager.get(windowType)
if (win?.webContents && win.webContents !== senderWebContents) {
win.webContents.send(IRPCActionType.SYNC_ALBUM_SOURCE, source)
}
}
})
export {
albumToolboxRouter
}
@@ -1,17 +1,17 @@
import { Menu, MenuItemConstructorOptions } from 'electron'
import { builtInGalleryToolboxMenu } from './builtIn'
import { builtInAlbumToolboxMenu } from './builtIn'
import picgo from '@core/picgo'
import GuiApi from 'apis/gui'
import windowManager from '~/main/apis/app/window/windowManager'
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
import logger from '~/main/apis/core/picgo/logger'
class GalleryMenuListManager {
class AlbumMenuListManager {
private menuList: MenuItemConstructorOptions[] = []
private menu: Menu | null = null
private getBuiltInMenuList (selectedList: IGalleryItem[]): MenuItemConstructorOptions[] {
const builtInMenu = builtInGalleryToolboxMenu().map(item => {
private getBuiltInMenuList (selectedList: IAlbumItem[]): MenuItemConstructorOptions[] {
const builtInMenu = builtInAlbumToolboxMenu().map(item => {
return {
label: item.label,
async click () {
@@ -20,7 +20,7 @@ class GalleryMenuListManager {
} catch (e: any) {
logger.error(e)
} finally {
windowManager.get(IWindowList.SETTING_WINDOW)?.webContents.send(IRPCActionType.UPDATE_GALLERY)
windowManager.get(IWindowList.SETTING_WINDOW)?.webContents.send(IRPCActionType.UPDATE_ALBUM)
}
}
}
@@ -30,19 +30,19 @@ class GalleryMenuListManager {
return this.menuList
}
private getMenuItemList (selectedList: IGalleryItem[]) {
private getMenuItemList (selectedList: IAlbumItem[]) {
// current only support built-in menu
return this.getBuiltInMenuList(selectedList)
}
public getMenu (selectedList: IGalleryItem[]): Menu {
public getMenu (selectedList: IAlbumItem[]): Menu {
this.menu = Menu.buildFromTemplate(this.getMenuItemList(selectedList))
return this.menu
}
}
const galleryMenuListManager = new GalleryMenuListManager()
const albumMenuListManager = new AlbumMenuListManager()
export {
galleryMenuListManager
albumMenuListManager
}
+703
View File
@@ -0,0 +1,703 @@
import { IRPCActionType } from '~/universal/types/enum'
import { RPCRouter } from '../router'
import picgo from '@core/picgo'
import { AlbumDB } from '~/main/apis/core/datastore'
import type { IPicGoCloudUserInfo } from '#/types/cloud'
import type {
CloudAlbumImportAllResult,
CloudAlbumListResponse,
CloudAlbumBatchUpdateResult,
CloudAlbumImportResult,
CloudAlbumFiltersResponse,
CloudAlbumStatsResponse
} from '#/types/cloudAlbum'
import {
ConfigSyncManager,
ConflictType,
E2EAskPinReason,
EncryptionMethod,
IBuildInEvent,
SyncStatus,
type AlbumListQuery,
type IDiffNode,
type IConfig
} from 'picgo'
import { T } from '~/main/i18n'
import { fail, ok } from '../utils'
import GuiApi from 'apis/gui'
import fs from 'fs-extra'
import { parse } from 'comment-json'
import { cloneDeep, isPlainObject, set, unset } from 'lodash'
import path from 'path'
import logger from 'apis/core/picgo/logger'
import {
IPicGoCloudConfigSyncConflictChoice,
IPicGoCloudConfigSyncRunStatus,
IPicGoCloudConfigSyncSessionStatus,
IPicGoCloudConfigSyncToastType,
IPicGoCloudEncryptionMethod,
type IPicGoCloudConfigSyncConflictItem,
type IPicGoCloudConfigSyncResolution,
type IPicGoCloudConfigSyncRunResult,
type IPicGoCloudConfigSyncState
} from '#/types/cloudConfigSync'
const cloudRouter = new RPCRouter()
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
const USER_ABORTED_CODE = 'PICGO_CLOUD_CONFIG_SYNC_ABORTED'
/**
* Config sync session state MUST live in the main process (memory only) so the UI can re-hydrate
* after window hide/show without losing an in-progress/conflict state.
*/
let configSyncSessionStatus: IPicGoCloudConfigSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.IDLE
let configSyncConflictDiffTree: IDiffNode | null = null
let configSyncConflictItems: IPicGoCloudConfigSyncConflictItem[] = []
let configSyncManager: ConfigSyncManager | null = null
const clearConfigSyncSession = (): void => {
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.IDLE
configSyncConflictDiffTree = null
configSyncConflictItems = []
}
const logConfigSyncOutcome = (
stage: 'sync' | 'applyResolvedConfig',
status: SyncStatus,
message?: string,
meta: { conflictCount?: number } = {}
): void => {
const prefix = `[PicGo Cloud][config-sync][${stage}]`
if (status === SyncStatus.SUCCESS) {
logger.info(`${prefix} success`)
return
}
if (status === SyncStatus.CONFLICT) {
const count = typeof meta.conflictCount === 'number' ? meta.conflictCount : 0
logger.warn(`${prefix} conflict`, `count=${count}`)
return
}
if (message === USER_ABORTED_CODE || message === 'Invalid PIN input') {
logger.warn(`${prefix} aborted`)
return
}
logger.error(`${prefix} failed`, message || '')
}
const getLocalEncryptionMethod = (): IPicGoCloudEncryptionMethod | undefined => {
const value = picgo.getConfig<unknown>('settings.picgoCloud.encryptionMethod')
if (
value === IPicGoCloudEncryptionMethod.AUTO
|| value === IPicGoCloudEncryptionMethod.SSE
|| value === IPicGoCloudEncryptionMethod.E2EE
) {
return value
}
return undefined
}
const toSyncEncryptionMethod = (method?: IPicGoCloudEncryptionMethod): EncryptionMethod | undefined => {
if (method === IPicGoCloudEncryptionMethod.AUTO) return EncryptionMethod.AUTO
if (method === IPicGoCloudEncryptionMethod.SSE) return EncryptionMethod.SSE
if (method === IPicGoCloudEncryptionMethod.E2EE) return EncryptionMethod.E2EE
return undefined
}
const getSnapshotUpdatedAt = async (): Promise<string | undefined> => {
try {
const snapshotPath = path.join(picgo.baseDir, 'config.snapshot.json')
if (!(await fs.pathExists(snapshotPath))) return undefined
const content = await fs.readFile(snapshotPath, 'utf8')
const parsed: unknown = parse(content)
if (!isPlainObject(parsed)) return undefined
const updatedAt = (parsed as { updatedAt?: unknown }).updatedAt
return typeof updatedAt === 'string' && updatedAt ? updatedAt : undefined
} catch {
return undefined
}
}
const buildConfigSyncState = async (): Promise<IPicGoCloudConfigSyncState> => {
return {
sessionStatus: configSyncSessionStatus,
encryptionMethod: getLocalEncryptionMethod(),
lastSyncedAt: await getSnapshotUpdatedAt(),
conflicts: configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.CONFLICT ? configSyncConflictItems : undefined
}
}
const readLocalConfigWithComments = async (): Promise<IConfig> => {
if (!(await fs.pathExists(picgo.configPath))) {
return picgo.getConfig<IConfig>()
}
const content = await fs.readFile(picgo.configPath, 'utf8')
const parsed: unknown = parse(content)
if (!isPlainObject(parsed)) {
throw new Error(T('PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID'))
}
return parsed as IConfig
}
const extractConflictItems = (diffTree: IDiffNode): IPicGoCloudConfigSyncConflictItem[] => {
const items: IPicGoCloudConfigSyncConflictItem[] = []
const walk = (node: IDiffNode, pathSegments: string[]) => {
const nextSegments = node.key === 'root' ? pathSegments : [...pathSegments, node.key]
if (node.status === ConflictType.CONFLICT) {
// If the conflict is an object-level aggregation, surface leaf conflicts instead.
if (node.children && node.children.length > 0) {
node.children.forEach(child => walk(child, nextSegments))
return
}
items.push({
path: nextSegments.join('.'),
localValue: node.localValue,
remoteValue: node.remoteValue
})
return
}
if (node.children && node.children.length > 0) {
node.children.forEach(child => walk(child, nextSegments))
}
}
walk(diffTree, [])
return items
}
const localizeConfigSyncResult = (status: SyncStatus, message: string | undefined): { message: string, toastType: IPicGoCloudConfigSyncToastType } => {
if (status === SyncStatus.SUCCESS) {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_SUCCESS'),
toastType: IPicGoCloudConfigSyncToastType.SUCCESS
}
}
if (status === SyncStatus.CONFLICT) {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED'),
toastType: IPicGoCloudConfigSyncToastType.INFO
}
}
const raw = message || T('PICGO_CLOUD_CONFIG_SYNC_FAILED')
const isEncryptionSwitchCancelled = message === picgo.i18n.translate('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
if (raw === USER_ABORTED_CODE || raw === 'Invalid PIN input' || isEncryptionSwitchCancelled) {
return {
message: isEncryptionSwitchCancelled
? T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
: T('PICGO_CLOUD_CONFIG_SYNC_ABORTED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING
}
}
if (raw === 'Maximum retry attempts exceeded') {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY'),
toastType: IPicGoCloudConfigSyncToastType.ERROR
}
}
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON', { reason: raw }),
toastType: IPicGoCloudConfigSyncToastType.ERROR
}
}
const getEncryptionMethodLabel = (method: EncryptionMethod): string => {
if (method === EncryptionMethod.E2EE) return T('PICGO_CLOUD_ENCRYPTION_MODE_E2E')
return T('PICGO_CLOUD_ENCRYPTION_MODE_SERVER')
}
const getConfigSyncManager = (): ConfigSyncManager => {
if (configSyncManager) return configSyncManager
const guiApi = GuiApi.getInstance()
configSyncManager = new ConfigSyncManager(picgo, {
onAskPin: async (reason: E2EAskPinReason, retryCount: number) => {
const inputOptions: IShowInputBoxOption = {
title: (() => {
if (reason === E2EAskPinReason.SETUP) return T('PICGO_CLOUD_E2E_PIN_SETUP_TITLE')
if (reason === E2EAskPinReason.DECRYPT) return T('PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE')
return T('PICGO_CLOUD_E2E_PIN_RETRY_TITLE', { retryCount })
})(),
placeholder: T('PICGO_CLOUD_E2E_PIN_PLACEHOLDER'),
inputType: 'password',
width: 520,
confirm: reason === E2EAskPinReason.SETUP
? { placeholder: T('PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER') }
: undefined
}
const value = await guiApi.showInputBox(inputOptions)
if (!value) {
// Throw a sentinel code so we can treat it as a user-aborted flow in the GUI.
throw new Error(USER_ABORTED_CODE)
}
return value
},
onAskEncryptionSwitch: async ({ from, to }: { from: EncryptionMethod, to: EncryptionMethod }): Promise<boolean> => {
const title = T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE')
const message = T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY', {
from: getEncryptionMethodLabel(from),
to: getEncryptionMethodLabel(to)
})
const res = await guiApi.showMessageBox({
title,
message,
type: 'warning',
buttons: [
T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM'),
T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL')
]
})
return res.result === 0
}
})
return configSyncManager
}
const buildResolvedConfig = async (resolution: IPicGoCloudConfigSyncResolution): Promise<IConfig> => {
const base = await readLocalConfigWithComments()
for (const item of configSyncConflictItems) {
const choice = resolution[item.path]
if (choice === IPicGoCloudConfigSyncConflictChoice.CLOUD) {
if (item.remoteValue === undefined) {
unset(base, item.path)
} else {
set(base, item.path, cloneDeep(item.remoteValue))
}
}
}
return base
}
type PicGoCloudGetUserInfoOptions = {
refresh?: boolean
}
const getUserInfo = async (options?: PicGoCloudGetUserInfoOptions): Promise<IPicGoCloudUserInfo | null> => {
return options?.refresh === true
? await picgo.cloud.refreshUserInfo()
: await picgo.cloud.getUserInfo()
}
const loginWithTimeout = async (): Promise<void> => {
const loginPromise = picgo.cloud.login()
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
picgo.cloud.disposeLoginFlow()
reject(new Error(T('PICGO_CLOUD_LOGIN_TIMEOUT')))
}, LOGIN_TIMEOUT_MS)
})
try {
await Promise.race([loginPromise, timeoutPromise])
} finally {
if (timeoutId) clearTimeout(timeoutId)
// Avoid unhandled rejection when timeout disposes the core login flow.
loginPromise.catch((error) => {
picgo.log.warn(error instanceof Error ? error.message : String(error))
})
}
}
cloudRouter
.add(IRPCActionType.PICGO_CLOUD_GET_USER_INFO, async (args) => {
try {
const [options] = args as [PicGoCloudGetUserInfoOptions | undefined]
const userInfo = await getUserInfo(options)
return ok(userInfo)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_LOGIN, async () => {
try {
await loginWithTimeout()
const userInfo = await getUserInfo()
if (!userInfo) {
return fail(T('PICGO_CLOUD_LOGIN_FAILED'))
}
return ok(userInfo)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_LOGOUT, async () => {
try {
picgo.cloud.logout()
clearConfigSyncSession()
return ok(true)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_DISPOSE_LOGIN_FLOW, async () => {
try {
picgo.cloud.disposeLoginFlow()
return ok(true)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_GET_STATE, async () => {
try {
return ok(await buildConfigSyncState())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_SET_E2E_PREFERENCE, async (args) => {
try {
const [mode] = args as [IPicGoCloudEncryptionMethod]
if (mode === IPicGoCloudEncryptionMethod.AUTO) {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.AUTO
})
} else if (mode === IPicGoCloudEncryptionMethod.SSE) {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.SSE
})
} else {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.E2EE
})
}
return ok(getLocalEncryptionMethod())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_ABORT, async () => {
try {
clearConfigSyncSession()
return ok(await buildConfigSyncState())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START, async () => {
const fallbackState = await buildConfigSyncState()
if (configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.SYNCING) {
logger.info('[PicGo Cloud][config-sync][sync] already in progress')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS'),
toastType: IPicGoCloudConfigSyncToastType.INFO,
state: fallbackState
}
return ok(runRes)
}
if (configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.CONFLICT) {
logger.info('[PicGo Cloud][config-sync][sync] pending conflict session')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.CONFLICT,
message: T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING'),
toastType: IPicGoCloudConfigSyncToastType.INFO,
state: fallbackState
}
return ok(runRes)
}
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.SYNCING
try {
const userInfo = await getUserInfo()
if (!userInfo) {
logger.warn('[PicGo Cloud][config-sync][sync] login expired')
clearConfigSyncSession()
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_LOGIN_EXPIRED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING,
authInvalidated: true,
state: await buildConfigSyncState()
}
return ok(runRes)
}
const manager = getConfigSyncManager()
const encryptionMethod = toSyncEncryptionMethod(getLocalEncryptionMethod())
const res = encryptionMethod
? await manager.sync({ encryptionMethod })
: await manager.sync()
if (res.status === SyncStatus.CONFLICT && res.diffTree) {
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
configSyncConflictDiffTree = res.diffTree
configSyncConflictItems = extractConflictItems(res.diffTree)
logConfigSyncOutcome('sync', res.status, res.message, { conflictCount: configSyncConflictItems.length })
} else {
clearConfigSyncSession()
logConfigSyncOutcome('sync', res.status, res.message)
}
const localized = localizeConfigSyncResult(res.status, res.message)
const runStatus = res.status === SyncStatus.SUCCESS
? IPicGoCloudConfigSyncRunStatus.SUCCESS
: res.status === SyncStatus.CONFLICT
? IPicGoCloudConfigSyncRunStatus.CONFLICT
: IPicGoCloudConfigSyncRunStatus.FAILED
const runRes: IPicGoCloudConfigSyncRunResult = {
status: runStatus,
message: localized.message,
toastType: localized.toastType,
shouldShowRestartPrompt: res.status === SyncStatus.SUCCESS,
state: await buildConfigSyncState()
}
return ok(runRes)
} catch (e) {
logger.error('[PicGo Cloud][config-sync][sync] error', e)
clearConfigSyncSession()
const localized = localizeConfigSyncResult(SyncStatus.FAILED, e instanceof Error ? e.message : String(e))
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: localized.message,
toastType: localized.toastType,
state: await buildConfigSyncState()
}
return ok(runRes)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_APPLY_RESOLUTION, async (args) => {
try {
const [resolution] = args as [IPicGoCloudConfigSyncResolution]
if (configSyncSessionStatus !== IPicGoCloudConfigSyncSessionStatus.CONFLICT || !configSyncConflictDiffTree) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] no conflict session')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION'),
toastType: IPicGoCloudConfigSyncToastType.ERROR,
state: await buildConfigSyncState()
}
return ok(runRes)
}
const expectedPaths = new Set(configSyncConflictItems.map(item => item.path))
const providedPaths = new Set(Object.keys(resolution))
for (const path of expectedPaths) {
if (!providedPaths.has(path)) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] resolution incomplete')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE'),
toastType: IPicGoCloudConfigSyncToastType.ERROR,
state: await buildConfigSyncState()
}
return ok(runRes)
}
}
const userInfo = await getUserInfo()
if (!userInfo) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] login expired')
clearConfigSyncSession()
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_LOGIN_EXPIRED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING,
authInvalidated: true,
state: await buildConfigSyncState()
}
return ok(runRes)
}
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.SYNCING
const resolvedConfig = await buildResolvedConfig(resolution)
const encryptionMethod = getLocalEncryptionMethod()
const manager = getConfigSyncManager()
const applyRes = await manager.applyResolvedConfig(
resolvedConfig,
encryptionMethod === IPicGoCloudEncryptionMethod.E2EE
? { useE2E: true }
: encryptionMethod === IPicGoCloudEncryptionMethod.SSE
? { useE2E: false }
: {}
)
if (applyRes.status === SyncStatus.SUCCESS) {
clearConfigSyncSession()
} else {
// Keep conflict session so the user can retry.
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
}
logConfigSyncOutcome('applyResolvedConfig', applyRes.status, applyRes.message, { conflictCount: configSyncConflictItems.length })
const localized = localizeConfigSyncResult(applyRes.status, applyRes.message)
const runStatus = applyRes.status === SyncStatus.SUCCESS
? IPicGoCloudConfigSyncRunStatus.SUCCESS
: applyRes.status === SyncStatus.CONFLICT
? IPicGoCloudConfigSyncRunStatus.CONFLICT
: IPicGoCloudConfigSyncRunStatus.FAILED
const runRes: IPicGoCloudConfigSyncRunResult = {
status: runStatus,
message: localized.message,
toastType: localized.toastType,
shouldShowRestartPrompt: applyRes.status === SyncStatus.SUCCESS,
state: await buildConfigSyncState()
}
return ok(runRes)
} catch (e) {
logger.error('[PicGo Cloud][config-sync][applyResolvedConfig] error', e)
// Keep the conflict session so user can retry from UI.
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
const localized = localizeConfigSyncResult(SyncStatus.FAILED, e instanceof Error ? e.message : String(e))
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: localized.message,
toastType: localized.toastType,
state: await buildConfigSyncState()
}
return ok(runRes)
}
})
// --- Cloud Album RPC handlers ---
cloudRouter
.add(IRPCActionType.PICGO_CLOUD_ALBUM_LIST, async (args) => {
try {
const [query] = args as [AlbumListQuery | undefined]
logger.debug('[PicGo Cloud][album][list]', JSON.stringify(query ?? {}))
const result = await picgo.cloud.album.list(query ?? {})
return ok(result as CloudAlbumListResponse)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_DELETE, async (args) => {
try {
const [ids] = args as [string | string[]]
logger.debug('[PicGo Cloud][album][delete]', JSON.stringify(ids))
await picgo.cloud.album.delete(ids)
return ok(true)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_UPDATE, async (args) => {
try {
const [id, data] = args as [string, Partial<ImgInfo>]
logger.debug('[PicGo Cloud][album][update]', `id=${id}`, JSON.stringify(data))
const result = await picgo.cloud.album.update(id, data)
return ok(result)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_BATCH_UPDATE, async (args) => {
try {
const [items] = args as [{ id: string, data: Partial<ImgInfo> }[]]
logger.debug('[PicGo Cloud][album][batchUpdate]', `count=${items.length}`)
const result = await picgo.cloud.album.batchUpdate(items)
return ok(result as CloudAlbumBatchUpdateResult)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT, async (args) => {
try {
const [items] = args as [ImgInfo[]]
logger.debug('[PicGo Cloud][album][import]', `count=${items.length}`)
const result = await picgo.cloud.album.import(items)
return ok(result as CloudAlbumImportResult)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_GET_STATS, async () => {
try {
logger.debug('[PicGo Cloud][album][getStats]')
const result = await picgo.cloud.album.getStats()
return ok(result as CloudAlbumStatsResponse)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_ALBUM_GET_FILTERS, async () => {
try {
logger.debug('[PicGo Cloud][album][getFilters]')
const result = await picgo.cloud.album.getFilters()
return ok(result as CloudAlbumFiltersResponse)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_SET_AUTO_IMPORT, async (args) => {
try {
const [autoImport] = args as [boolean]
logger.debug('[PicGo Cloud][album][setAutoImport]', `autoImport=${autoImport}`)
const userInfo = await picgo.cloud.setAutoImport(autoImport)
return ok(userInfo)
} catch (e) {
return fail(e)
}
})
cloudRouter
.add(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT_ALL, async () => {
try {
logger.debug('[PicGo Cloud][album][importAll] starting')
// 1. Enable auto-import
const userInfo = await picgo.cloud.setAutoImport(true)
logger.debug('[PicGo Cloud][album][importAll] user info', JSON.stringify(userInfo, null, 2))
// 2. Read all local gallery items
const albumResult = await AlbumDB.getInstance().get({ orderBy: 'desc' })
const localItems = albumResult.data as ImgInfo[]
logger.debug('[PicGo Cloud][album][importAll]', `localItems=${localItems.length}`)
let created = 0
if (localItems.length > 0) {
const importResult = await picgo.cloud.album.import(localItems)
created = importResult.created
if (created > 0) {
picgo.emit(IBuildInEvent.CLOUD_ALBUM_UPDATED)
}
}
const result: CloudAlbumImportAllResult = { userInfo, created }
return ok(result)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_GET_USAGE, async () => {
try {
const usage = await picgo.cloud.getUsage()
return ok(usage)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_GET_BILLING_OVERVIEW, async () => {
try {
const overview = await picgo.cloud.getBillingOverview()
return ok(overview)
} catch (e) {
return fail(e)
}
})
export {
cloudRouter
}
+108 -16
View File
@@ -1,34 +1,126 @@
import { IRPCActionType } from '~/universal/types/enum'
import { RPCRouter } from '../router'
import { copyUploaderConfig, deleteUploaderConfig, getUploaderConfigList, selectUploaderConfig, updateUploaderConfig } from '~/main/utils/handleUploaderConfig'
import picgo from '@core/picgo'
import { T } from '~/main/i18n'
import { fail, ok } from '../utils'
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
const configRouter = new RPCRouter()
configRouter
.add(IRPCActionType.CHANGE_CURRENT_UPLOADER, async (args) => {
try {
const [type, configName] = args as ISelectUploaderConfigArgs
if (configName) {
const activeConfig = picgo.uploaderConfig.use(type, configName)
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
}
notifyAppConfigUpdated()
return ok(activeConfig._id)
}
const activeConfig = picgo.uploaderConfig.use(type)
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
}
notifyAppConfigUpdated()
return ok(activeConfig._id)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.GET_PICBED_CONFIG_LIST, async (args) => {
const [type] = args as IGetUploaderConfigListArgs
const config = getUploaderConfigList(type)
return config
try {
const [type] = args as IGetUploaderConfigListArgs
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.DELETE_PICBED_CONFIG, async (args) => {
const [type, id] = args as IDeleteUploaderConfigArgs
const config = deleteUploaderConfig(type, id)
return config
try {
const [type, configName] = args as IDeleteUploaderConfigArgs
const existing = picgo.uploaderConfig.getConfigList(type)
if (existing.length <= 1) {
throw new Error(T('TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST'))
}
picgo.uploaderConfig.remove(type, configName)
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
notifyAppConfigUpdated()
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.COPY_UPLOADER_CONFIG, async (args) => {
const [type, id] = args as ICopyUploaderConfigArgs
const config = copyUploaderConfig(type, id)
return config
try {
const [type, configName, newConfigName] = args as ICopyUploaderConfigArgs
picgo.uploaderConfig.copy(type, configName, newConfigName)
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
notifyAppConfigUpdated()
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.SELECT_UPLOADER, async (args) => {
const [type, id] = args as ISelectUploaderConfigArgs
selectUploaderConfig(type, id)
return true
try {
const [type, configName] = args as ISelectUploaderConfigArgs
const activeConfig = picgo.uploaderConfig.use(type, configName)
notifyAppConfigUpdated()
return ok(activeConfig._id)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.UPDATE_UPLOADER_CONFIG, async (args) => {
const [type, id, config] = args as IUpdateUploaderConfigArgs
updateUploaderConfig(type, id, config)
return true
try {
const [type, configId, config] = args as IUpdateUploaderConfigArgs
const configName = typeof config._configName === 'string' ? config._configName : ''
if (configId && !configName) {
throw new Error(T('TIPS_UPLOADER_CONFIG_NAME_EMPTY'))
}
let oldConfigName = ''
if (configId) {
const configList = picgo.uploaderConfig.getConfigList(type)
const existConfig = configList.find(item => item._id === configId)
if (!existConfig) {
throw new Error(T('TIPS_UPLOADER_CONFIG_NOT_FOUND'))
}
oldConfigName = existConfig._configName
}
if (oldConfigName && oldConfigName !== configName) {
picgo.uploaderConfig.rename(type, oldConfigName, configName)
}
picgo.uploaderConfig.createOrUpdate(type, configName, config)
notifyAppConfigUpdated()
return ok(true)
} catch (e) {
return fail(e)
}
})
export {
@@ -1,6 +0,0 @@
import { galleryMenu as changeURLGalleryMenu } from './changeURL'
export const builtInGalleryToolboxMenu = () => {
const menuList = [...changeURLGalleryMenu()]
return menuList
}
@@ -1,20 +0,0 @@
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
import { RPCRouter } from '../../router'
import windowManager from '~/main/apis/app/window/windowManager'
import { galleryMenuListManager } from './menuListManager'
const galleryToolboxRouter = new RPCRouter()
galleryToolboxRouter.add(IRPCActionType.GET_GALLERY_MENU_LIST, async (args) => {
const [selectedList] = args as IGetGalleryMenuListArgs
const win = windowManager.get(IWindowList.SETTING_WINDOW)!
const menu = galleryMenuListManager.getMenu(selectedList)
menu.popup({
window: win
})
})
export {
galleryToolboxRouter
}
+242
View File
@@ -0,0 +1,242 @@
import path from 'path'
import fs from 'fs-extra'
import picgo from '@core/picgo'
import { evaluatePluginConfig } from 'picgo'
import { RPCRouter } from '../router'
import { fail, ok } from '../utils'
import { IRPCActionType } from '~/universal/types/enum'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
import { T } from '~/main/i18n'
import { showNotification } from '~/main/utils/common'
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
import { dialog } from 'electron'
import windowManager from '~/main/apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
function handleRestoreState (fullName: string) {
const plugin = picgo.pluginLoader.getPlugin(fullName)
if (!plugin) {
return
}
const currentUploader =
picgo.getConfig<string>('picBed.uploader') ||
picgo.getConfig<string>('picBed.current') ||
'smms'
const currentTransformer = picgo.getConfig<string>('picBed.transformer') || 'path'
const uploaderName = plugin.uploader || ''
const transformerName = plugin.transformer || ''
const configToRestore: Record<string, string> = {}
if (uploaderName && currentUploader === uploaderName) {
configToRestore['picBed.current'] = 'smms'
configToRestore['picBed.uploader'] = 'smms'
}
if (transformerName && currentTransformer === transformerName) {
configToRestore['picBed.transformer'] = 'path'
}
if (Object.keys(configToRestore).length > 0) {
picgo.saveConfig(configToRestore)
}
}
const pluginsRouter = new RPCRouter()
pluginsRouter
.add(IRPCActionType.INSTALL_PLUGIN, async (args) => {
const [fullName] = args as [string]
const result = await picgo.pluginHandler.install([fullName])
if (!result.success) {
showNotification({
title: T('PLUGIN_INSTALL_FAILED'),
body: result.body as string
})
return fail(new Error(result.body as string))
}
shortKeyHandler.registerPluginShortKey(result.body[0])
return ok(result.body[0])
})
.add(IRPCActionType.IMPORT_LOCAL_PLUGIN, async () => {
try {
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)
if (!settingWindow) {
throw new Error('Setting window not found')
}
const dialogResult = await dialog.showOpenDialog(settingWindow, {
properties: ['openDirectory']
})
const filePaths = dialogResult.filePaths
if (filePaths.length === 0) {
return ok(null)
}
const result = await picgo.pluginHandler.install(filePaths)
if (!result.success) {
showNotification({
title: T('PLUGIN_IMPORT_FAILED'),
body: result.body as string
})
return fail(new Error(result.body as string))
}
shortKeyHandler.registerPluginShortKey(result.body[0])
return ok(result.body[0])
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.UNINSTALL_PLUGIN, async (args) => {
const [fullName] = args as [string]
handleRestoreState(fullName)
const result = await picgo.pluginHandler.uninstall([fullName])
if (!result.success) {
showNotification({
title: T('PLUGIN_UNINSTALL_FAILED'),
body: result.body as string
})
return fail(new Error(result.body as string))
}
shortKeyHandler.unregisterPluginShortKey(result.body[0])
picgo.saveConfig({
needReload: true
})
notifyAppConfigUpdated()
return ok(result.body[0])
})
.add(IRPCActionType.UPDATE_PLUGIN, async (args) => {
const [fullName] = args as [string]
const result = await picgo.pluginHandler.update([fullName])
if (!result.success) {
showNotification({
title: T('PLUGIN_UPDATE_FAILED'),
body: result.body as string
})
return fail(new Error(result.body as string))
}
picgo.saveConfig({
needReload: true
})
notifyAppConfigUpdated()
return ok(result.body[0])
})
.add(IRPCActionType.ENABLE_PLUGIN, async (args) => {
try {
const [fullName] = args as [string]
picgo.saveConfig({
[`picgoPlugins.${fullName}`]: true,
needReload: true
})
notifyAppConfigUpdated()
return ok(fullName)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.DISABLE_PLUGIN, async (args) => {
try {
const [fullName] = args as [string]
handleRestoreState(fullName)
picgo.saveConfig({
[`picgoPlugins.${fullName}`]: false,
needReload: true
})
notifyAppConfigUpdated()
return ok(fullName)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.GET_INSTALLED_PLUGIN_README, async (args) => {
try {
const [fullName] = args as [string]
if (typeof fullName !== 'string' || fullName.length === 0) {
return ok('')
}
const pluginDir = path.join(picgo.baseDir, 'node_modules', fullName)
for (const candidate of README_FILE_CANDIDATES) {
const readmePath = path.join(pluginDir, candidate)
if (await fs.pathExists(readmePath)) {
const content = await fs.readFile(readmePath, 'utf-8')
return ok(content)
}
}
return ok('')
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.REFRESH_CONFIG_SCHEMA, async (args) => {
try {
const [payload] = args as [IRefreshConfigSchemaArgs]
const draftValues = payload.draftValues ?? {}
let rawSchema: unknown[] | null = null
if (payload.target === 'plugin') {
const plugin = picgo.pluginLoader.getPlugin(payload.pluginFullName)
if (plugin?.config) {
rawSchema = plugin.config(picgo)
}
} else if (payload.target === 'transformer') {
const transformerName = picgo.pluginLoader.getPlugin(payload.pluginFullName)?.transformer
if (transformerName) {
const handler = picgo.helper.transformer.get(transformerName)
if (handler?.config) {
rawSchema = handler.config(picgo)
}
}
} else if (payload.target === 'uploader') {
const handler = picgo.helper.uploader.get(payload.uploaderName)
if (handler?.config) {
rawSchema = handler.config(picgo)
}
}
if (!rawSchema) {
picgo.log.warn(`[plugin-config] refresh target not found: ${JSON.stringify(payload)}`)
return ok([])
}
const resolved = evaluatePluginConfig(rawSchema as Parameters<typeof evaluatePluginConfig>[0], draftValues, {
onError: (fieldName, kind, error) => {
const message = error instanceof Error ? error.message : String(error)
picgo.log.warn(`[plugin-config] ${fieldName}.${kind} threw during refresh: ${message}`)
}
})
return ok(resolved)
} catch (e) {
return fail(e)
}
})
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> }
export {
pluginsRouter
}
+8 -1
View File
@@ -1,6 +1,6 @@
import { IRPCActionType, IWindowList } from '~/universal/types/enum'
import { RPCRouter } from '../router'
import { app, clipboard, shell } from 'electron'
import { app, BrowserWindow, clipboard, shell } from 'electron'
import windowManager from '~/main/apis/app/window/windowManager'
import { handleMenubarIcon } from '~/main/apis/app/system'
import { PICGO_NOTIFICATION_CLICKED } from '~/universal/events/constants'
@@ -13,6 +13,13 @@ systemRouter
app.relaunch()
app.exit(0)
})
.add(IRPCActionType.GET_WINDOW_STATE, async (_args, event) => {
const senderWindow = BrowserWindow.fromWebContents(event.sender)
return {
isMaximized: senderWindow?.isMaximized() ?? false
}
})
.add(IRPCActionType.OPEN_FILE, async (args) => {
const [filePath] = args as IOpenFileArgs
shell.openPath(filePath)
+15 -16
View File
@@ -1,16 +1,15 @@
import fs from 'fs-extra'
import { IpcMainEvent } from 'electron'
import { IToolboxItemCheckStatus, IToolboxItemType } from '~/universal/types/enum'
import { sendToolboxResWithType } from './utils'
import { dbPathChecker, getGalleryDBPath } from '~/main/apis/core/datastore/dbChecker'
import { GalleryDB } from '~/main/apis/core/datastore'
import { dbPathChecker, getAlbumDBPath } from '~/main/apis/core/datastore/dbChecker'
import { AlbumDB } from '~/main/apis/core/datastore'
import path from 'path'
import { T } from '~/main/i18n'
export const checkFileMap: IToolboxCheckerMap<
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_ALBUM_FILE_BROKEN
> = {
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async (event: IpcMainEvent) => {
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async (event) => {
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_CONFIG_FILE_BROKEN)
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.LOADING
@@ -35,17 +34,17 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
})
}
},
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: async (event) => {
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_GALLERY_FILE_BROKEN)
[IToolboxItemType.IS_ALBUM_FILE_BROKEN]: async (event) => {
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_ALBUM_FILE_BROKEN)
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.LOADING
})
const { dbPath } = getGalleryDBPath()
const galleryDB = GalleryDB.getInstance()
if (galleryDB.errorList.length === 0) {
const { dbPath } = getAlbumDBPath()
const albumDB = AlbumDB.getInstance()
if (albumDB.errorList.length === 0) {
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.SUCCESS,
msg: T('TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS', {
msg: T('TOOLBOX_CHECK_ALBUM_FILE_PATH_TIPS', {
path: dbPath
}),
value: path.dirname(dbPath)
@@ -53,7 +52,7 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
} else {
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.ERROR,
msg: T('TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS'),
msg: T('TOOLBOX_CHECK_ALBUM_FILE_BROKEN_TIPS'),
value: path.dirname(dbPath)
})
}
@@ -61,7 +60,7 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
}
export const fixFileMap: IToolboxFixMap<
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_ALBUM_FILE_BROKEN
> = {
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async () => {
try {
@@ -74,14 +73,14 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
status: IToolboxItemCheckStatus.SUCCESS
}
},
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: async () => {
[IToolboxItemType.IS_ALBUM_FILE_BROKEN]: async () => {
try {
fs.unlinkSync(getGalleryDBPath().dbPath)
fs.unlinkSync(getAlbumDBPath().dbPath)
} catch (e) {
// do nothing
}
return {
type: IToolboxItemType.IS_GALLERY_FILE_BROKEN,
type: IToolboxItemType.IS_ALBUM_FILE_BROKEN,
status: IToolboxItemCheckStatus.SUCCESS
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { IpcMainEvent } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { IRPCActionType, IToolboxItemType } from '~/universal/types/enum'
export const sendToolboxResWithType = (type: IToolboxItemType) => (event: IpcMainEvent, res?: Omit<IToolboxCheckRes, 'type'>) => {
export const sendToolboxResWithType = (type: IToolboxItemType) => (event: IpcMainEvent | IpcMainInvokeEvent, res?: Omit<IToolboxCheckRes, 'type'>) => {
return event.sender.send(IRPCActionType.TOOLBOX_CHECK_RES, {
...res,
type
+53
View File
@@ -0,0 +1,53 @@
import logger from '@core/picgo/logger'
const errorToMessage = (e: unknown): string => {
if (e instanceof Error) return e.message
return String(e)
}
/**
* 从抛出的 error 里提取错误码字符串。两个字段都查,优先级:apiCode > code。
*
* - apiCodepicgo-core `createCloudServiceError` 包装后挂上的业务错误码字段,
* 值来自后端响应体里的 `data.code`(如 'ACCOUNT_FROZEN' / 'QUOTA_EXCEEDED')。
* 走过 picgo-core 包装的云端调用,错误码都在这里。
*
* - code:兜底字段,覆盖两种场景:
* 1) axios 底层错误自带的 `code`(如 'ECONNREFUSED' / 'ETIMEDOUT'),未被
* picgo-core 包装就穿透到这里时,错误码仅存在于 `code`;
* 2) 其他非 picgo-core 抛出的自定义错误对象用 `code` 字段。
*
* 防御性保留两者,避免因调用路径不一致丢失错误码。
*/
const errorToCode = (e: unknown): string | undefined => {
if (!e || typeof e !== 'object') return undefined
const maybe = e as { apiCode?: unknown, code?: unknown }
if (typeof maybe.apiCode === 'string') return maybe.apiCode
if (typeof maybe.code === 'string') return maybe.code
return undefined
}
export const ok = <T>(data: T): IRPCResult<T> => ({
success: true,
data
})
export const fail = <T>(e: unknown): IRPCResult<T> => {
const code = errorToCode(e)
const error = errorToMessage(e)
logger.error(`[RPC][FAIL] code=${code} message=${error}`)
return {
success: false,
error,
...(code ? { code } : {})
}
}
export const isIRPCResult = (value: unknown): value is IRPCResult<any> => {
if (!value || typeof value !== 'object') return false
const maybe = value as { success?: unknown, data?: unknown, error?: unknown }
if (typeof maybe.success !== 'boolean') return false
// success=true MUST have data; success=false MUST have error.
if (maybe.success) return 'data' in maybe
return typeof maybe.error === 'string'
}
+2 -2
View File
@@ -10,8 +10,8 @@ class I18nManager {
private builtinI18nFolder = getStaticPath('i18n')
private outerI18nFolder = ''
private localesMap: Map<string, ILocales> = new Map()
private currentLanguage: string = 'zh-CN'
readonly defaultLanguage: string = 'zh-CN'
private currentLanguage: string = 'en'
readonly defaultLanguage: string = 'en'
private i18nFileList: II18nItem[] = builtinI18nList
setOuterI18nFolder (folder: string) {
+3 -3
View File
@@ -12,7 +12,7 @@ import { IRemoteNoticeTriggerHook, IWindowList } from '#/types/enum'
import windowManager from 'apis/app/window/windowManager'
import {
updateShortKeyFromVersion212,
migrateGalleryFromVersion230
migrateAlbumFromVersion230
} from '~/main/migrate'
import {
uploadSelectedFiles,
@@ -25,7 +25,7 @@ import server from '~/main/server/index'
import updateChecker from '~/main/utils/updateChecker'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
import { getUploadFiles } from '~/main/utils/handleArgv'
import { GalleryDB } from '~/main/apis/core/datastore'
import { AlbumDB } from '~/main/apis/core/datastore'
import bus from '@core/bus'
import logger from 'apis/core/picgo/logger'
import picgo from 'apis/core/picgo'
@@ -66,7 +66,7 @@ class LifeCycle {
ipcList.listen()
busEventList.listen()
updateShortKeyFromVersion212(picgo)
await migrateGalleryFromVersion230(GalleryDB.getInstance(), picgo)
await migrateAlbumFromVersion230(AlbumDB.getInstance(), picgo)
}
private onReady () {
+6 -6
View File
@@ -11,7 +11,7 @@ const updateShortKeyFromVersion212 = (picgo: PicGoCore) => {
if (shortKeyConfig === undefined) {
const defaultShortKeyConfig = {
enable: true,
key: 'CommandOrControl+Shift+P',
key: 'CommandOrControl+Shift+U',
name: 'upload',
label: T('QUICK_UPLOAD')
}
@@ -39,8 +39,8 @@ const updateShortKeyFromVersion212 = (picgo: PicGoCore) => {
return false
}
const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore) => {
const originGallery = picgo.getConfig<ImgInfo[] | undefined>('uploaded')
const migrateAlbumFromVersion230 = async (albumDB: DBStore, picgo: PicGoCore) => {
const originAlbum = picgo.getConfig<ImgInfo[] | undefined>('uploaded')
// if hasMigrate, we don't need to migrate
const hasMigrate = picgo.getConfig<boolean | undefined>('__migrateUploaded') === true
if (hasMigrate) {
@@ -49,11 +49,11 @@ const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore
const configPath = picgo.configPath
const configBakPath = path.join(path.dirname(configPath), 'config.bak.json')
// migrate gallery from config to gallery db
if (originGallery && Array.isArray(originGallery) && originGallery?.length > 0) {
if (originAlbum && Array.isArray(originAlbum) && originAlbum?.length > 0) {
if (fse.existsSync(configBakPath)) {
fse.copyFileSync(configPath, configBakPath)
}
await galleryDB.insertMany(originGallery)
await albumDB.insertMany(originAlbum)
picgo.saveConfig({
uploaded: [],
__migrateUploaded: true
@@ -63,5 +63,5 @@ const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore
export {
updateShortKeyFromVersion212,
migrateGalleryFromVersion230
migrateAlbumFromVersion230
}
+40 -101
View File
@@ -1,34 +1,18 @@
import http from 'http'
import routers from './routerManager'
import {
handleResponse,
ensureHTTPLink
} from './utils'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import axios from 'axios'
import formUploader from './middlewares/formUploader'
import windowManager from 'apis/app/window/windowManager'
import {
uploadClipboardFilesWithInfo,
uploadSelectedFilesWithInfo
} from 'apis/app/uploader/apis'
import { getFormImageFolderPath } from 'apis/core/datastore/dbChecker'
import type { IInternalServerManager } from 'picgo/dist/types/internal'
class Server {
private httpServer: http.Server
private config: IServerConfig
private hasInstalledUploadAdapter = false
constructor () {
let config = picgo.getConfig<IServerConfig>('settings.server')
const result = this.checkIfConfigIsValid(config)
if (result) {
this.config = config
} else {
config = {
port: 36677,
host: '127.0.0.1',
enable: true
}
this.config = config
picgo.saveConfig({
'settings.server': config
})
}
this.httpServer = http.createServer(this.handleRequest)
this.config = this.ensureConfig()
}
private checkIfConfigIsValid (config: IObj | undefined) {
@@ -39,98 +23,53 @@ class Server {
}
}
private handleRequest = (request: http.IncomingMessage, response: http.ServerResponse) => {
if (request.method === 'OPTIONS') {
handleResponse({
response
})
return
private ensureConfig (): IServerConfig {
let config = picgo.getConfig<IServerConfig>('settings.server')
if (this.checkIfConfigIsValid(config)) {
return config
}
if (request.method === 'POST') {
if (!routers.getHandler(request.url!)) {
logger.warn(`[PicGo Server] don't support [${request.url}] url`)
handleResponse({
response,
statusCode: 404,
body: {
success: false
}
})
} else if (formUploader.isFileUpload(request)) {
formUploader.handleFileUpload(request, response)
} else {
let body: string = ''
let postObj: IObj
request.on('data', chunk => {
body += chunk
})
request.on('end', () => {
try {
postObj = (body === '') ? {} : JSON.parse(body)
} catch (err: any) {
logger.error('[PicGo Server]', err)
return handleResponse({
response,
body: {
success: false,
message: 'Not sending data in JSON format'
}
})
}
logger.info('[PicGo Server] get the request', body)
const handler = routers.getHandler(request.url!)
handler!({
...postObj,
response
})
})
}
} else {
logger.warn(`[PicGo Server] don't support [${request.method}] method`)
response.statusCode = 404
response.end()
config = {
port: 36677,
host: '127.0.0.1',
enable: true
}
picgo.saveConfig({
'settings.server': config
})
return config
}
// port as string is a bug
private listen = (port: number | string) => {
logger.info(`[PicGo Server] is listening at ${port}`)
if (typeof port === 'string') {
port = parseInt(port, 10)
}
this.httpServer.listen(port, this.config.host).on('error', async (err: ErrnoException) => {
if (err.errno === 'EADDRINUSE') {
try {
// make sure the system has a PicGo Server instance
await axios.post(ensureHTTPLink(`${this.config.host}:${port}/heartbeat`))
this.shutdown(true)
} catch (e) {
logger.warn(`[PicGo Server] ${port} is busy, trying with port ${(port as number) + 1}`)
// fix a bug: not write an increase number to config file
// to solve the auto number problem
this.listen((port as number) + 1)
}
}
private ensureUploadAdapterInstalled () {
if (this.hasInstalledUploadAdapter) return
const server = picgo.server as IInternalServerManager
server.setUploadAdapter({
uploadClipboard: async () => await uploadClipboardFilesWithInfo(),
uploadPaths: async (paths: string[]) => {
const win = windowManager.getAvailableWindow()
return await uploadSelectedFilesWithInfo(win.webContents, paths.map(item => ({ path: item })))
},
getTempDir: getFormImageFolderPath
})
this.hasInstalledUploadAdapter = true
}
startup () {
console.log('startup', this.config.enable)
this.config = this.ensureConfig()
if (this.config.enable) {
this.listen(this.config.port)
this.ensureUploadAdapterInstalled()
// let core resolve config defaults when possible, but preserve GUI config semantics.
const port = typeof this.config.port === 'string' ? parseInt(this.config.port, 10) : this.config.port
picgo.server.listen(port, this.config.host)
}
}
shutdown (hasStarted?: boolean) {
this.httpServer.close()
if (!hasStarted) {
logger.info('[PicGo Server] shutdown')
}
shutdown () {
picgo.server.shutdown()
logger.info('[PicGo Server] shutdown')
}
restart () {
this.config = picgo.getConfig('settings.server')
this.shutdown()
this.startup()
}
-125
View File
@@ -1,125 +0,0 @@
import http from 'http'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { dbPathDir } from 'apis/core/datastore/dbChecker'
import logger from '@core/picgo/logger'
import { handleResponse } from '../utils'
import routers from '../routerManager'
import { FORM_IMAGE_FOLDER } from '~/universal/utils/static'
// Multer 错误类型定义
export interface MulterError extends Error {
code: string
field?: string
storageErrors?: any[]
}
// Multer 中间件适配器类型
export type MulterMiddleware = (
req: http.IncomingMessage,
res: http.ServerResponse,
callback: (error?: MulterError) => void
) => void
// 扩展 multer 函数的返回类型
declare module 'multer' {
interface Multer {
array(fieldname: string, maxCount?: number): MulterMiddleware;
single(fieldname: string): MulterMiddleware;
fields(fields: { name: string; maxCount?: number }[]): MulterMiddleware;
none(): MulterMiddleware;
}
}
class FormUploader {
private upload!: multer.Multer
private formImagesPath!: string
constructor () {
this.initializeStorage()
this.setupMulter()
}
private initializeStorage () {
const STORE_PATH = dbPathDir()
this.formImagesPath = path.join(STORE_PATH, FORM_IMAGE_FOLDER)
if (!fs.existsSync(this.formImagesPath)) {
fs.mkdirSync(this.formImagesPath, { recursive: true })
}
}
private setupMulter () {
const storage = multer.diskStorage({
destination: (req: http.IncomingMessage, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
cb(null, this.formImagesPath)
},
filename: (req: http.IncomingMessage, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
cb(null, file.originalname || Date.now() + path.extname(file.originalname))
}
})
this.upload = multer({
storage
})
}
/**
* 处理文件上传的中间件
*/
public handleFileUpload = (request: http.IncomingMessage, response: http.ServerResponse): void => {
logger.info('[PicGo Server] handling file upload')
this.upload.array('files')(request, response, async (err?: MulterError) => {
if (err) {
logger.error('[PicGo Server] file upload error', err)
return handleResponse({
response,
body: {
success: false,
message: 'File upload failed'
}
})
}
try {
const files = request.files
if (!files || files.length === 0) {
return handleResponse({
response,
body: {
success: false,
message: 'No files were uploaded'
}
})
}
const filePaths = files.map((file) => file.path)
logger.info('[PicGo Server] files uploaded: ' + filePaths.join(', '))
const handler = routers.getHandler(request.url!)
handler!({
list: filePaths,
response
})
} catch (err: any) {
logger.error('[PicGo Server] process upload files error', err)
handleResponse({
response,
body: {
success: false,
message: 'Failed to process uploaded files'
}
})
}
})
}
/**
* 检查请求是否为文件上传
*/
public isFileUpload (request: http.IncomingMessage): boolean {
return !!(request.headers['content-type'] && request.headers['content-type'].includes('multipart/form-data'))
}
}
export default new FormUploader()
-21
View File
@@ -1,21 +0,0 @@
class Router {
private router = new Map<string, routeHandler>()
get (url: string, callback: routeHandler): void {
this.router.set(url, callback)
}
post (url: string, callback: routeHandler): void {
this.router.set(url, callback)
}
getHandler (url: string) {
if (this.router.has(url)) {
return this.router.get(url)
} else {
return null
}
}
}
export default new Router()
-100
View File
@@ -1,100 +0,0 @@
import router from './router'
import {
handleResponse
} from './utils'
import logger from '@core/picgo/logger'
import windowManager from 'apis/app/window/windowManager'
import { uploadSelectedFiles, uploadClipboardFiles } from 'apis/app/uploader/apis'
import path from 'path'
import { dbPathDir } from 'apis/core/datastore/dbChecker'
const STORE_PATH = dbPathDir()
const LOG_PATH = path.join(STORE_PATH, 'picgo.log')
const errorMessage = `upload error. see ${LOG_PATH} for more detail.`
router.post('/upload', async ({
response,
list = []
} : {
response: IHttpResponse,
list?: string[]
}): Promise<void> => {
try {
if (list.length === 0) {
// upload with clipboard
logger.info('[PicGo Server] upload clipboard file')
const res = await uploadClipboardFiles()
logger.info('[PicGo Server] upload result:', res)
if (res) {
handleResponse({
response,
body: {
success: true,
result: [res]
}
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
} else {
logger.info('[PicGo Server] upload files in list')
// upload with files
const pathList = list.map(item => {
return {
path: item
}
})
const win = windowManager.getAvailableWindow()
const res = await uploadSelectedFiles(win.webContents, pathList)
logger.info('[PicGo Server] upload result', res.join(' ; '))
if (res.length) {
handleResponse({
response,
body: {
success: true,
result: res
}
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
}
} catch (err: any) {
logger.error(err)
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
})
router.post('/heartbeat', async ({
response
} : {
response: IHttpResponse,
}) => {
handleResponse({
response,
body: {
success: true,
result: 'alive'
}
})
})
export default router
-33
View File
@@ -1,33 +0,0 @@
import logger from '@core/picgo/logger'
export const handleResponse = ({
response,
statusCode = 200,
header = {
'Content-Type': 'application/json',
'access-control-allow-headers': '*',
'access-control-allow-methods': 'POST, GET, OPTIONS',
'access-control-allow-origin': '*'
},
body = {
success: false
}
} : {
response: IHttpResponse,
statusCode?: number,
header?: IObj,
body?: any
}) => {
if (body?.success === false) {
logger.warn('[PicGo Server] upload failed, see picgo.log for more detail ↑')
}
response.writeHead(statusCode, header)
response.write(JSON.stringify(body))
response.end()
}
export const ensureHTTPLink = (url: string): string => {
return url.startsWith('http')
? url
: `http://${url}`
}
+18
View File
@@ -0,0 +1,18 @@
import { APP_CONFIG_UPDATED } from '#/events/constants'
import { IWindowList } from '#/types/enum'
import windowManager from 'apis/app/window/windowManager'
const TARGET_WINDOWS: IWindowList[] = [
IWindowList.SETTING_WINDOW,
IWindowList.TRAY_WINDOW,
IWindowList.MINI_WINDOW,
IWindowList.RENAME_WINDOW,
IWindowList.TOOLBOX_WINDOW
]
export const notifyAppConfigUpdated = (): void => {
TARGET_WINDOWS.forEach((windowType) => {
if (!windowManager.has(windowType)) return
windowManager.get(windowType)?.webContents.send(APP_CONFIG_UPDATED)
})
}
+98 -23
View File
@@ -1,34 +1,109 @@
// for referer policy, we can't use it in renderer
import axios from 'axios'
import { RELEASE_URL, RELEASE_URL_BACKUP } from '../../universal/utils/static'
import semver from 'semver'
import yaml from 'js-yaml'
interface IGithubRelease {
tag_name?: string
name?: string
prerelease?: boolean
draft?: boolean
}
interface IReleaseYAML {
version?: unknown
}
const REQUEST_HEADERS = {
Referer: 'https://github.com'
}
function normalizeVersion (version: unknown): string {
if (typeof version !== 'string') {
return ''
}
const normalized = version.trim().replace(/^v/i, '')
return semver.valid(normalized) ?? ''
}
function pickLatestVersion (versions: string[]): string {
return versions.reduce((latest, current) => {
if (latest === '' || semver.gt(current, latest)) {
return current
}
return latest
}, '')
}
async function fetchLatestVersionFromGitHub (isCheckBetaUpdate: boolean): Promise<string> {
const response = await axios.get(RELEASE_URL, {
headers: REQUEST_HEADERS
})
const releaseList: IGithubRelease[] = Array.isArray(response.data) ? response.data : []
const versions = releaseList.flatMap((release) => {
if (release.draft) {
return []
}
if (!isCheckBetaUpdate && release.prerelease) {
return []
}
const version = normalizeVersion(release.tag_name ?? release.name)
return version ? [version] : []
})
return pickLatestVersion(versions)
}
async function fetchVersionFromBackupYAML (fileName: 'latest.yml' | 'latest.beta.yml'): Promise<string> {
const response = await axios.get(`${RELEASE_URL_BACKUP}/${fileName}`, {
headers: REQUEST_HEADERS
})
const releaseInfo = yaml.load(response.data)
if (typeof releaseInfo !== 'object' || releaseInfo === null) {
return ''
}
return normalizeVersion((releaseInfo as IReleaseYAML).version)
}
async function fetchLatestVersionFromBackup (isCheckBetaUpdate: boolean): Promise<string> {
if (!isCheckBetaUpdate) {
return fetchVersionFromBackupYAML('latest.yml')
}
const settled = await Promise.allSettled([
fetchVersionFromBackupYAML('latest.yml'),
fetchVersionFromBackupYAML('latest.beta.yml')
])
const versions: string[] = []
settled.forEach((item) => {
if (item.status === 'fulfilled' && item.value) {
versions.push(item.value)
}
})
return pickLatestVersion(versions)
}
export const getLatestVersion = async (isCheckBetaUpdate: boolean = false) => {
let res: string = ''
try {
res = await axios.get(RELEASE_URL, {
headers: {
Referer: 'https://github.com'
}
}).then(r => {
const list = r.data as IStringKeyMap[]
if (isCheckBetaUpdate) {
const betaList = list.filter(item => item.name.includes('beta'))
return betaList[0].name
}
const normalList = list.filter(item => !item.name.includes('beta'))
return normalList[0].name
}).catch(async () => {
const result = await axios.get(isCheckBetaUpdate ? `${RELEASE_URL_BACKUP}/latest.beta.yml` : `${RELEASE_URL_BACKUP}/latest.yml`, {
headers: {
Referer: 'https://github.com'
}
})
const r = yaml.load(result.data) as IStringKeyMap
return r.version
})
const version = await fetchLatestVersionFromGitHub(isCheckBetaUpdate)
if (version) {
return version
}
} catch (err) {
console.log(err)
}
return res
try {
return await fetchLatestVersionFromBackup(isCheckBetaUpdate)
} catch (err) {
console.log(err)
return ''
}
}
+9 -13
View File
@@ -3,19 +3,15 @@ import picgo from '@core/picgo'
const getPicBeds = () => {
const picBedTypes = picgo.helper.uploader.getIdList()
const picBedFromDB = picgo.getConfig<IPicBedType[]>('picBed.list') || []
const picBeds = picBedTypes.map((item: string) => {
const visible = picBedFromDB.find((i: IPicBedType) => i.type === item) // object or undefined
return {
type: item,
name: picgo.helper.uploader.get(item)!.name || item,
visible: visible ? visible.visible : true
}
}).sort((a) => {
if (a.type === 'tcyun') {
return -1
}
return 0
}) as IPicBedType[]
const picBeds = picBedTypes
.map((item: string) => {
const visible = picBedFromDB.find((i: IPicBedType) => i.type === item) // object or undefined
return {
type: item,
name: picgo.helper.uploader.get(item)!.name || item,
visible: visible ? visible.visible : true
}
}) as IPicBedType[]
return picBeds
}

Some files were not shown because too many files have changed in this diff Show More