mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-10-19 16:14:41 +00:00
✨ Feature(server): support per-upload configurations (#1447)
Forward per-upload Core options through the GUI HTTP server, isolate upload task window state, scope rename listener cleanup, and align uploader fallbacks with PicGo Cloud. Add regression coverage and PR/mainline commitlint and test checks.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited, ready_for_review]
|
||||
push:
|
||||
branches: [dev, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# PR runs include all its commits. Keep separate runs for every mainline push.
|
||||
group: ci-check-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HUSKY: '0'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: ${{ matrix.check }}
|
||||
if: github.event.deleted != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check: [Commitlint, Tests]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.7.0
|
||||
run_install: false
|
||||
|
||||
- name: Locate pnpm store
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-24-pnpm-11.7.0-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-24-pnpm-11.7.0-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check commit messages
|
||||
if: matrix.check == 'Commitlint'
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
if [[ -z "$BASE_SHA" || "$BASE_SHA" == '0000000000000000000000000000000000000000' ]]; then
|
||||
# New branches have no previous tip; do not lint unrelated legacy history.
|
||||
pnpm exec commitlint --last --verbose
|
||||
else
|
||||
if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags origin "$BASE_SHA"
|
||||
fi
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
|
||||
fi
|
||||
|
||||
- name: Check PR title for squash merges
|
||||
if: matrix.check == 'Commitlint' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --verbose
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.check == 'Tests'
|
||||
run: pnpm test
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
"motion": "^12.35.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"picgo": "^3.0.1",
|
||||
"picgo": "^3.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"qrcode.vue": "^3.3.3",
|
||||
|
||||
Generated
+36
-29
@@ -126,8 +126,8 @@ importers:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
picgo:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
prismjs:
|
||||
specifier: ^1.30.0
|
||||
version: 1.30.0
|
||||
@@ -2620,6 +2620,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
JSONStream@1.3.5:
|
||||
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
|
||||
@@ -3249,10 +3250,12 @@ packages:
|
||||
conventional-changelog-atom@2.0.8:
|
||||
resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-codemirror@2.0.8:
|
||||
resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-conventionalcommits@10.4.0:
|
||||
resolution: {integrity: sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==}
|
||||
@@ -3265,26 +3268,32 @@ packages:
|
||||
conventional-changelog-core@4.2.4:
|
||||
resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead.
|
||||
|
||||
conventional-changelog-ember@2.0.9:
|
||||
resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-eslint@3.0.9:
|
||||
resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-express@2.0.6:
|
||||
resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-jquery@3.0.11:
|
||||
resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-jshint@2.0.9:
|
||||
resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead.
|
||||
|
||||
conventional-changelog-preset-loader@2.3.4:
|
||||
resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==}
|
||||
@@ -3900,6 +3909,7 @@ packages:
|
||||
eslint@9.39.1:
|
||||
resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -4295,6 +4305,7 @@ packages:
|
||||
git-raw-commits@2.0.11:
|
||||
resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||
hasBin: true
|
||||
|
||||
git-remote-origin-url@2.0.0:
|
||||
@@ -4304,6 +4315,7 @@ packages:
|
||||
git-semver-tags@4.1.1:
|
||||
resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||
hasBin: true
|
||||
|
||||
gitconfiglocal@1.0.0:
|
||||
@@ -4319,11 +4331,12 @@ packages:
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4519,9 +4532,9 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
image-size@0.8.3:
|
||||
resolution: {integrity: sha512-SMtq1AJ+aqHB45c3FsB4ERK0UCiA2d3H1uq8s+8T0Pf8A3W4teyBQyaFaktH6xvZqh+npwlKU7i4fJo0r7TYTg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
image-size-next@2.1.1:
|
||||
resolution: {integrity: sha512-n+DFjUct+G9mxZck+lvzqrTsqBJvSHMs6iEo//W5iAgRV7oUbrh1JWmKgAEpmyRB5lw6plIQizS1wK1dvrsvAw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
immediate@3.0.6:
|
||||
@@ -5727,8 +5740,8 @@ packages:
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
picgo@3.0.1:
|
||||
resolution: {integrity: sha512-6tovqMARqXJiLIDc6NpOO4pRJnftwdJPRB+S0qVcyfyHxWIgomrxes6EI4gL4H2r/EEA4oJ93RwVIbZM6j7YEA==}
|
||||
picgo@3.1.0:
|
||||
resolution: {integrity: sha512-8GlFd7VaSs92MJpub/qW20Klbvx/FAe/V1MV29l6b2p5s66NTFKN73hqzWS2RZ/phTpjHNxycwq/96uXr6B8wg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5877,9 +5890,6 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue@6.0.1:
|
||||
resolution: {integrity: sha512-AJBQabRCCNr9ANq8v77RJEv73DPbn55cdTb+Giq4X0AVnNVZvMHlYp7XlQiN+1npCZj1DuSmaA2hYVUUDgxFDg==}
|
||||
|
||||
quick-lru@4.0.1:
|
||||
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6532,10 +6542,12 @@ packages:
|
||||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
tar@7.5.2:
|
||||
resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
temp-file@3.4.0:
|
||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||
@@ -6800,6 +6812,7 @@ packages:
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
@@ -8310,7 +8323,7 @@ snapshots:
|
||||
node-gyp: 11.5.0
|
||||
ora: 5.4.1
|
||||
read-binary-file-arch: 1.0.6
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
tar: 6.2.1
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
@@ -8322,7 +8335,7 @@ snapshots:
|
||||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.3.2
|
||||
fs-extra: 11.3.3
|
||||
minimatch: 9.0.5
|
||||
plist: 3.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -12087,7 +12100,7 @@ snapshots:
|
||||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
@@ -12286,9 +12299,7 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
image-size@0.8.3:
|
||||
dependencies:
|
||||
queue: 6.0.1
|
||||
image-size-next@2.1.1: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
@@ -12339,7 +12350,7 @@ snapshots:
|
||||
cli-width: 2.2.1
|
||||
external-editor: 3.1.0
|
||||
figures: 2.0.0
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
mute-stream: 0.0.7
|
||||
run-async: 2.4.1
|
||||
rxjs: 6.6.7
|
||||
@@ -12355,7 +12366,7 @@ snapshots:
|
||||
cli-width: 3.0.0
|
||||
external-editor: 3.1.0
|
||||
figures: 3.2.0
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
mute-stream: 0.0.8
|
||||
ora: 5.4.1
|
||||
run-async: 2.4.1
|
||||
@@ -13105,14 +13116,14 @@ snapshots:
|
||||
|
||||
node-abi@4.24.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
@@ -13132,7 +13143,7 @@ snapshots:
|
||||
make-fetch-happen: 14.0.3
|
||||
nopt: 8.1.0
|
||||
proc-log: 5.0.0
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
tar: 7.5.2
|
||||
tinyglobby: 0.2.15
|
||||
which: 5.0.0
|
||||
@@ -13156,7 +13167,7 @@ snapshots:
|
||||
dependencies:
|
||||
hosted-git-info: 4.1.0
|
||||
is-core-module: 2.16.1
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
validate-npm-package-license: 3.0.4
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
@@ -13418,7 +13429,7 @@ snapshots:
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
picgo@3.0.1:
|
||||
picgo@3.1.0:
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.12.3)
|
||||
'@picgo/i18n': 1.0.0
|
||||
@@ -13436,7 +13447,7 @@ snapshots:
|
||||
giget: 2.0.0
|
||||
globby: 11.1.0
|
||||
hono: 4.12.3
|
||||
image-size: 0.8.3
|
||||
image-size-next: 2.1.1
|
||||
inquirer: 6.5.2
|
||||
is-wsl: 2.2.0
|
||||
js-yaml: 4.1.1
|
||||
@@ -13566,10 +13577,6 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue@6.0.1:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
quick-lru@4.0.1: {}
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import { IRPCActionType } from '~/universal/types/enum'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
|
||||
interface TestPlugin {
|
||||
uploader?: string
|
||||
transformer?: string
|
||||
}
|
||||
|
||||
const pluginFullName = 'picgo-plugin-test'
|
||||
const configValues: Record<string, string | undefined> = {}
|
||||
let activePlugin: TestPlugin | undefined
|
||||
|
||||
const getPluginMock = vi.fn((_fullName: string) => activePlugin)
|
||||
const getConfigMock = vi.fn((key: string) => configValues[key])
|
||||
const saveConfigMock = vi.fn()
|
||||
const uninstallMock = vi.fn()
|
||||
const unregisterPluginShortKeyMock = vi.fn()
|
||||
const notifyAppConfigUpdatedMock = vi.fn()
|
||||
|
||||
vi.mock('@core/picgo', () => ({
|
||||
default: {
|
||||
baseDir: '/tmp/picgo-test',
|
||||
getConfig: getConfigMock,
|
||||
saveConfig: saveConfigMock,
|
||||
pluginLoader: {
|
||||
getPlugin: getPluginMock
|
||||
},
|
||||
pluginHandler: {
|
||||
install: vi.fn(),
|
||||
uninstall: uninstallMock,
|
||||
update: vi.fn()
|
||||
},
|
||||
helper: {
|
||||
transformer: { get: vi.fn() },
|
||||
uploader: { get: vi.fn() }
|
||||
},
|
||||
log: {
|
||||
warn: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@core/picgo/logger', () => ({
|
||||
default: {
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: {
|
||||
showOpenDialog: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('apis/app/shortKey/shortKeyHandler', () => ({
|
||||
default: {
|
||||
registerPluginShortKey: vi.fn(),
|
||||
unregisterPluginShortKey: unregisterPluginShortKeyMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('apis/app/window/windowManager', () => ({
|
||||
default: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/i18n', () => ({
|
||||
T: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/common', () => ({
|
||||
showNotification: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/appConfigNotifier', () => ({
|
||||
notifyAppConfigUpdated: notifyAppConfigUpdatedMock
|
||||
}))
|
||||
|
||||
function createInvokeEvent (): IpcMainInvokeEvent {
|
||||
return {
|
||||
sender: {
|
||||
send: vi.fn()
|
||||
}
|
||||
} as unknown as IpcMainInvokeEvent
|
||||
}
|
||||
|
||||
async function invokePluginAction (action: IRPCActionType) {
|
||||
const { pluginsRouter } = await import('../../main/events/rpc/routes/plugins')
|
||||
const handler = pluginsRouter.routes().get(action)
|
||||
|
||||
expect(handler).toBeDefined()
|
||||
return handler?.([pluginFullName], createInvokeEvent())
|
||||
}
|
||||
|
||||
describe('plugins RPC restore state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(configValues).forEach((key) => delete configValues[key])
|
||||
activePlugin = {
|
||||
uploader: 'test-uploader',
|
||||
transformer: 'test-transformer'
|
||||
}
|
||||
uninstallMock.mockResolvedValue({
|
||||
success: true,
|
||||
body: [pluginFullName]
|
||||
})
|
||||
})
|
||||
|
||||
it('restores PicGo Cloud and the path transformer when disabling the active plugin', async () => {
|
||||
configValues['picBed.uploader'] = 'test-uploader'
|
||||
configValues['picBed.current'] = 'test-uploader'
|
||||
configValues['picBed.transformer'] = 'test-transformer'
|
||||
|
||||
const result = await invokePluginAction(IRPCActionType.DISABLE_PLUGIN)
|
||||
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(1, {
|
||||
'picBed.current': PICGO_CLOUD_UPLOADER_TYPE,
|
||||
'picBed.uploader': PICGO_CLOUD_UPLOADER_TYPE,
|
||||
'picBed.transformer': 'path'
|
||||
})
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(2, {
|
||||
[`picgoPlugins.${pluginFullName}`]: false,
|
||||
needReload: true
|
||||
})
|
||||
expect(notifyAppConfigUpdatedMock).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual({ success: true, data: pluginFullName })
|
||||
})
|
||||
|
||||
it('restores PicGo Cloud when uninstalling the active uploader plugin', async () => {
|
||||
configValues['picBed.uploader'] = 'test-uploader'
|
||||
configValues['picBed.current'] = 'test-uploader'
|
||||
configValues['picBed.transformer'] = 'path'
|
||||
|
||||
const result = await invokePluginAction(IRPCActionType.UNINSTALL_PLUGIN)
|
||||
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(1, {
|
||||
'picBed.current': PICGO_CLOUD_UPLOADER_TYPE,
|
||||
'picBed.uploader': PICGO_CLOUD_UPLOADER_TYPE
|
||||
})
|
||||
expect(uninstallMock).toHaveBeenCalledWith([pluginFullName])
|
||||
expect(unregisterPluginShortKeyMock).toHaveBeenCalledWith(pluginFullName)
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(2, {
|
||||
needReload: true
|
||||
})
|
||||
expect(notifyAppConfigUpdatedMock).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual({ success: true, data: pluginFullName })
|
||||
})
|
||||
|
||||
it('preserves an explicitly configured SM.MS uploader while restoring an active transformer', async () => {
|
||||
configValues['picBed.uploader'] = 'smms'
|
||||
configValues['picBed.current'] = 'smms'
|
||||
configValues['picBed.transformer'] = 'test-transformer'
|
||||
|
||||
await invokePluginAction(IRPCActionType.DISABLE_PLUGIN)
|
||||
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(1, {
|
||||
'picBed.transformer': 'path'
|
||||
})
|
||||
expect(saveConfigMock).toHaveBeenNthCalledWith(2, {
|
||||
[`picgoPlugins.${pluginFullName}`]: false,
|
||||
needReload: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,394 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createServer } from 'node:net'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { IImgInfo, IPicGo, IPlugin, IUploaderConfigItem, PicGo } from 'picgo'
|
||||
|
||||
interface TestProfile extends IUploaderConfigItem {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
interface UploadCapture {
|
||||
uploader: string
|
||||
endpoint: string
|
||||
origins: string[]
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
success: boolean
|
||||
result: string[]
|
||||
items: Array<{
|
||||
imgUrl?: string
|
||||
fileName?: string
|
||||
origin?: string
|
||||
type?: string
|
||||
}>
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type GuiServer = {
|
||||
startup: () => void
|
||||
shutdown: () => void
|
||||
}
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
configPath: '',
|
||||
formImagePath: ''
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const availableWebContents = {
|
||||
send: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
const trayWebContents = {
|
||||
send: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
const settingWebContents = {
|
||||
send: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
|
||||
return {
|
||||
availableWebContents,
|
||||
trayWebContents,
|
||||
settingWebContents,
|
||||
albumInsertMock: vi.fn(),
|
||||
handleCopyUrlMock: vi.fn(),
|
||||
showNotificationMock: vi.fn(),
|
||||
reportUploadDataMock: vi.fn(async () => {}),
|
||||
privacyCheckMock: vi.fn(async () => true),
|
||||
getClipboardFilePathListMock: vi.fn((): string[] => []),
|
||||
clipboardReadImageMock: vi.fn(() => ({
|
||||
isEmpty: () => false,
|
||||
toPNG: () => Buffer.from('builtin clipboard image')
|
||||
})),
|
||||
ipcMainOnMock: vi.fn(),
|
||||
ipcMainOnceMock: vi.fn(),
|
||||
ipcMainRemoveListenerMock: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('apis/core/datastore/dbChecker', () => ({
|
||||
dbChecker: () => {},
|
||||
dbPathChecker: () => testState.configPath,
|
||||
getFormImageFolderPath: () => testState.formImagePath
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: class {},
|
||||
clipboard: {
|
||||
readImage: mocks.clipboardReadImageMock
|
||||
},
|
||||
ipcMain: {
|
||||
on: mocks.ipcMainOnMock,
|
||||
once: mocks.ipcMainOnceMock,
|
||||
removeListener: mocks.ipcMainRemoveListenerMock
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn(async () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('apis/app/window/windowManager', () => ({
|
||||
default: {
|
||||
getAvailableWindow: () => ({ webContents: mocks.availableWebContents }),
|
||||
get: (name: string) => {
|
||||
if (name === 'TRAY_WINDOW') return { webContents: mocks.trayWebContents }
|
||||
if (name === 'SETTING_WINDOW') return { webContents: mocks.settingWebContents }
|
||||
return undefined
|
||||
},
|
||||
has: (name: string) => name === 'SETTING_WINDOW',
|
||||
create: vi.fn(),
|
||||
deleteById: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/apis/core/datastore', () => ({
|
||||
AlbumDB: {
|
||||
getInstance: () => ({
|
||||
insert: mocks.albumInsertMock
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/common', () => ({
|
||||
getClipboardFilePathList: mocks.getClipboardFilePathListMock,
|
||||
handleCopyUrl: mocks.handleCopyUrlMock,
|
||||
handleUrlEncodeWithSetting: (url: string) => url,
|
||||
showNotification: mocks.showNotificationMock
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/privacyManager', () => ({
|
||||
privacyManager: {
|
||||
check: mocks.privacyCheckMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/dataReport', () => ({
|
||||
dataReportManager: {
|
||||
reportUploadData: mocks.reportUploadDataMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/i18n', () => ({
|
||||
T: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('~/main/i18n/index', () => ({
|
||||
T: (key: string) => key
|
||||
}))
|
||||
|
||||
const reservePort = async (): Promise<number> => {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('Failed to reserve an integration-test port')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve())
|
||||
})
|
||||
return address.port
|
||||
}
|
||||
|
||||
const waitForServer = async (baseUrl: string): Promise<void> => {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/heartbeat`, { method: 'POST' })
|
||||
if (response.ok) return
|
||||
} catch (error: unknown) {
|
||||
lastError = error
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('GUI server did not start')
|
||||
}
|
||||
|
||||
const makeProfile = (id: string, name: string, endpoint: string): TestProfile => ({
|
||||
_id: id,
|
||||
_configName: name,
|
||||
_createdAt: 1,
|
||||
_updatedAt: 1,
|
||||
endpoint
|
||||
})
|
||||
|
||||
const parseUploadResponse = async (response: Response): Promise<UploadResponse> => {
|
||||
return await response.json() as UploadResponse
|
||||
}
|
||||
|
||||
describe('GUI server upload options integration', () => {
|
||||
const uploaderA = 'integration-uploader-a'
|
||||
const uploaderB = 'integration-uploader-b'
|
||||
const transformer = 'integration-transformer'
|
||||
const defaultA = makeProfile('a-default-id', 'Default A', 'a-default')
|
||||
const namedA = makeProfile('a-named-id', 'Named A', 'a-named')
|
||||
const defaultB = makeProfile('b-default-id', 'Default B', 'b-default')
|
||||
const selectedB = makeProfile('b-selected-id', 'Selected B', 'b-selected')
|
||||
const uploadCaptures: UploadCapture[] = []
|
||||
let tempPath = ''
|
||||
let jsonImagePath = ''
|
||||
let configFileBeforeUploads = ''
|
||||
let rootConfigBeforeUploads: unknown
|
||||
let baseUrl = ''
|
||||
let picgo: PicGo
|
||||
let guiServer: GuiServer
|
||||
|
||||
const expectConfigUnchanged = async () => {
|
||||
expect(structuredClone(picgo.getConfig())).toEqual(rootConfigBeforeUploads)
|
||||
await expect(readFile(testState.configPath, 'utf8')).resolves.toBe(configFileBeforeUploads)
|
||||
}
|
||||
|
||||
const integrationTransformer: IPlugin = {
|
||||
handle: async (ctx: IPicGo) => {
|
||||
ctx.output = await Promise.all(ctx.input.map(async (input): Promise<IImgInfo> => {
|
||||
if (typeof input !== 'string') return { ...input }
|
||||
const buffer = await readFile(input)
|
||||
return {
|
||||
buffer,
|
||||
contentType: 'image/png',
|
||||
extname: path.extname(input) || '.png',
|
||||
fileName: path.basename(input),
|
||||
filePath: input,
|
||||
origin: input,
|
||||
size: buffer.byteLength
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const integrationUploader: IPlugin = {
|
||||
handle: (ctx: IPicGo) => {
|
||||
const uploader = ctx.getConfig<string>('picBed.uploader')
|
||||
const profile = ctx.getConfig<TestProfile>(`picBed.${uploader}`)
|
||||
uploadCaptures.push({
|
||||
uploader,
|
||||
endpoint: profile.endpoint,
|
||||
origins: ctx.output.map(item => item.origin ?? '')
|
||||
})
|
||||
ctx.output = ctx.output.map(item => ({
|
||||
...item,
|
||||
imgUrl: `https://uploads.test/${profile.endpoint}/${item.fileName}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tempPath = await mkdtemp(path.join(tmpdir(), 'picgo-gui-server-options-'))
|
||||
testState.configPath = path.join(tempPath, 'config.json')
|
||||
testState.formImagePath = path.join(tempPath, 'picgo-form-images')
|
||||
jsonImagePath = path.join(tempPath, 'json image.png')
|
||||
await mkdir(path.join(tempPath, 'picgo-clipboard-images'), { recursive: true })
|
||||
await writeFile(testState.configPath, '{}')
|
||||
await writeFile(jsonImagePath, 'json image')
|
||||
|
||||
const port = await reservePort()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
picgo = (await import('@core/picgo')).default
|
||||
picgo.helper.transformer.register(transformer, integrationTransformer)
|
||||
picgo.helper.uploader.register(uploaderA, integrationUploader)
|
||||
picgo.helper.uploader.register(uploaderB, integrationUploader)
|
||||
picgo.saveConfig({
|
||||
'picBed.current': uploaderA,
|
||||
'picBed.uploader': uploaderA,
|
||||
'picBed.transformer': transformer,
|
||||
[`picBed.${uploaderA}`]: defaultA,
|
||||
[`picBed.${uploaderB}`]: defaultB,
|
||||
[`uploader.${uploaderA}`]: {
|
||||
configList: [defaultA, namedA],
|
||||
defaultId: defaultA._id
|
||||
},
|
||||
[`uploader.${uploaderB}`]: {
|
||||
configList: [defaultB, selectedB],
|
||||
defaultId: defaultB._id
|
||||
},
|
||||
'settings.server': {
|
||||
enable: true,
|
||||
host: '127.0.0.1',
|
||||
port
|
||||
},
|
||||
'settings.useBuiltinClipboard': true,
|
||||
'settings.pasteStyle': 'URL',
|
||||
'settings.customLink': '$url',
|
||||
'settings.rename': false,
|
||||
'settings.autoRename': false,
|
||||
'settings.uploadNotification': false
|
||||
})
|
||||
|
||||
guiServer = (await import('../../main/server')).default
|
||||
guiServer.startup()
|
||||
await waitForServer(baseUrl)
|
||||
|
||||
rootConfigBeforeUploads = structuredClone(picgo.getConfig())
|
||||
configFileBeforeUploads = await readFile(testState.configPath, 'utf8')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
uploadCaptures.length = 0
|
||||
mocks.albumInsertMock.mockClear()
|
||||
mocks.handleCopyUrlMock.mockClear()
|
||||
mocks.showNotificationMock.mockClear()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
guiServer?.shutdown()
|
||||
if (tempPath !== '') {
|
||||
await rm(tempPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('selects a named profile for a JSON path upload and keeps raw GUI results', async () => {
|
||||
const query = new URLSearchParams({ uploader: uploaderA, configName: namedA._configName })
|
||||
const response = await fetch(`${baseUrl}/upload?${query}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ list: [jsonImagePath] })
|
||||
})
|
||||
const body = await parseUploadResponse(response)
|
||||
const expectedUrl = `https://uploads.test/${namedA.endpoint}/json image.png`
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.result).toEqual([expectedUrl])
|
||||
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, type: uploaderA })
|
||||
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: namedA.endpoint, origins: [jsonImagePath] }])
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA }))
|
||||
await expectConfigUnchanged()
|
||||
})
|
||||
|
||||
it('selects a profile by ID for a multipart upload', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('files', new Blob(['multipart image'], { type: 'image/png' }), 'multipart image.png')
|
||||
const response = await fetch(`${baseUrl}/upload?configId=${selectedB._id}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const body = await parseUploadResponse(response)
|
||||
const expectedUrl = `https://uploads.test/${selectedB.endpoint}/multipart image.png`
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.result).toEqual([expectedUrl])
|
||||
expect(body.items[0]).toMatchObject({ imgUrl: expectedUrl, fileName: 'multipart image.png', type: uploaderB })
|
||||
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderB, endpoint: selectedB.endpoint })
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderB }))
|
||||
await expectConfigUnchanged()
|
||||
})
|
||||
|
||||
it('uses configName fallback after a missing ID on the builtin clipboard path', async () => {
|
||||
const query = new URLSearchParams({
|
||||
uploader: uploaderA,
|
||||
configId: 'missing-id',
|
||||
configName: defaultA._configName
|
||||
})
|
||||
const response = await fetch(`${baseUrl}/upload?${query}`, { method: 'POST' })
|
||||
const body = await parseUploadResponse(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.result[0]).toMatch(new RegExp(`^https://uploads\\.test/${defaultA.endpoint}/\\d+\\.png$`))
|
||||
expect(uploadCaptures[0]).toMatchObject({ uploader: uploaderA, endpoint: defaultA.endpoint })
|
||||
expect(mocks.clipboardReadImageMock).toHaveBeenCalled()
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: body.result[0], type: uploaderA }))
|
||||
await expectConfigUnchanged()
|
||||
})
|
||||
|
||||
it('retains the root default when no upload options are supplied', async () => {
|
||||
const response = await fetch(`${baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ list: [jsonImagePath] })
|
||||
})
|
||||
const body = await parseUploadResponse(response)
|
||||
const expectedUrl = `https://uploads.test/${defaultA.endpoint}/json image.png`
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.result).toEqual([expectedUrl])
|
||||
expect(uploadCaptures).toEqual([{ uploader: uploaderA, endpoint: defaultA.endpoint, origins: [jsonImagePath] }])
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(expect.objectContaining({ imgUrl: expectedUrl, type: uploaderA }))
|
||||
await expectConfigUnchanged()
|
||||
})
|
||||
|
||||
it('rejects invalid options before invoking the GUI upload adapter', async () => {
|
||||
const response = await fetch(`${baseUrl}/upload?uploader=missing-uploader`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ list: [jsonImagePath] })
|
||||
})
|
||||
const body = await parseUploadResponse(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toMatchObject({ success: false, code: 'UNKNOWN_UPLOADER', result: [], items: [] })
|
||||
expect(uploadCaptures).toEqual([])
|
||||
expect(mocks.albumInsertMock).not.toHaveBeenCalled()
|
||||
await expectConfigUnchanged()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { UploadOptions } from 'picgo'
|
||||
import type { IServerUploadAdapter } from 'picgo/dist/types/internal'
|
||||
|
||||
type ServerConfig = {
|
||||
port: number | string
|
||||
@@ -6,18 +8,12 @@ type ServerConfig = {
|
||||
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
|
||||
let installedUploadAdapter: IServerUploadAdapter | undefined
|
||||
|
||||
const getConfigMock = vi.fn((key?: string) => {
|
||||
if (key === 'settings.server') return serverConfig
|
||||
@@ -32,7 +28,7 @@ const saveConfigMock = vi.fn((patch: unknown) => {
|
||||
}
|
||||
})
|
||||
|
||||
const setUploadAdapterMock = vi.fn((adapter?: ServerUploadAdapter) => {
|
||||
const setUploadAdapterMock = vi.fn((adapter?: IServerUploadAdapter) => {
|
||||
installedUploadAdapter = adapter
|
||||
})
|
||||
const registerPostMock = vi.fn()
|
||||
@@ -179,10 +175,36 @@ describe('main/server (GUI adapter to picgo-core)', () => {
|
||||
|
||||
await expect(adapter.uploadClipboard()).resolves.toEqual([clipboardImage])
|
||||
await expect(adapter.uploadPaths(['/tmp/a.png'])).resolves.toEqual(selectedImages)
|
||||
expect(uploadClipboardFilesWithInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(uploadClipboardFilesWithInfoMock).toHaveBeenCalledWith(undefined)
|
||||
expect(getAvailableWindowMock).toHaveBeenCalledTimes(1)
|
||||
expect(uploadSelectedFilesWithInfoMock).toHaveBeenCalledWith(webContents, [{ path: '/tmp/a.png' }])
|
||||
expect(adapter.getTempDir()).toBe('/tmp/picgo-form-images')
|
||||
expect(uploadSelectedFilesWithInfoMock).toHaveBeenCalledWith(webContents, [{ path: '/tmp/a.png' }], undefined)
|
||||
expect(adapter.getTempDir?.()).toBe('/tmp/picgo-form-images')
|
||||
expect(getFormImageFolderPathMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards all Core upload options through clipboard and path adapters', async () => {
|
||||
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
|
||||
const webContents = { send: vi.fn() }
|
||||
const options: UploadOptions = {
|
||||
uploader: 'github',
|
||||
configName: 'Work',
|
||||
configId: 'github-work-id'
|
||||
}
|
||||
getAvailableWindowMock.mockReturnValue({ webContents })
|
||||
uploadClipboardFilesWithInfoMock.mockResolvedValue([])
|
||||
uploadSelectedFilesWithInfoMock.mockResolvedValue([])
|
||||
|
||||
const mod = await import('../../main/server')
|
||||
const server = mod.default
|
||||
server.startup()
|
||||
|
||||
expect(installedUploadAdapter).toBeDefined()
|
||||
const adapter = installedUploadAdapter!
|
||||
|
||||
await adapter.uploadClipboard(options)
|
||||
await adapter.uploadPaths(['/tmp/a.png'], options)
|
||||
|
||||
expect(uploadClipboardFilesWithInfoMock).toHaveBeenCalledWith(options)
|
||||
expect(uploadSelectedFilesWithInfoMock).toHaveBeenCalledWith(webContents, [{ path: '/tmp/a.png' }], options)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import { REGISTER_DEVICE_ID, TALKING_DATA_EVENT } from '~/universal/events/constants'
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface WebContentsStub {
|
||||
send: ReturnType<typeof vi.fn>
|
||||
isDestroyed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface TalkingDataPayload {
|
||||
EventId: string
|
||||
MapKv: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appGetVersion: vi.fn(() => '3.0.2-test'),
|
||||
deviceIdGetId: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
getVideoDuration: vi.fn(),
|
||||
ipcMainOnce: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: mocks.appGetVersion
|
||||
},
|
||||
ipcMain: {
|
||||
once: mocks.ipcMainOnce
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../main/utils/deviceId', () => ({
|
||||
deviceIdManager: {
|
||||
getId: mocks.deviceIdGetId
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@core/picgo', () => ({
|
||||
default: {
|
||||
getConfig: mocks.getConfig
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@picgo/video-duration', () => ({
|
||||
getVideoDuration: mocks.getVideoDuration
|
||||
}))
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolvePromise: (value: T) => void = () => undefined
|
||||
let rejectPromise: (reason?: unknown) => void = () => undefined
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: resolvePromise,
|
||||
reject: rejectPromise
|
||||
}
|
||||
}
|
||||
|
||||
const createWebContents = (): WebContentsStub => ({
|
||||
send: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
})
|
||||
|
||||
const asWebContents = (webContents: WebContentsStub): WebContents => {
|
||||
return webContents as unknown as WebContents
|
||||
}
|
||||
|
||||
const isTalkingDataPayload = (value: unknown): value is TalkingDataPayload => {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
if (!('EventId' in value) || typeof value.EventId !== 'string') return false
|
||||
return 'MapKv' in value && typeof value.MapKv === 'object' && value.MapKv !== null
|
||||
}
|
||||
|
||||
const getTalkingDataPayloads = (webContents: WebContentsStub): TalkingDataPayload[] => {
|
||||
return webContents.send.mock.calls.flatMap(([channel, payload]) => {
|
||||
if (channel !== TALKING_DATA_EVENT || !isTalkingDataPayload(payload)) return []
|
||||
return [payload]
|
||||
})
|
||||
}
|
||||
|
||||
const getPayloadByEventId = (payloads: TalkingDataPayload[], eventId: string): TalkingDataPayload => {
|
||||
const payload = payloads.find(item => item.EventId === eventId)
|
||||
if (!payload) {
|
||||
throw new Error(`Missing analytics event ${eventId}`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
describe('upload data reporting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mocks.deviceIdGetId.mockResolvedValue('device-id')
|
||||
mocks.getConfig.mockImplementation((key: string) => {
|
||||
if (key === 'picBed.uploader') return 'root-default'
|
||||
if (key === 'picBed.current') return 'root-current'
|
||||
return undefined
|
||||
})
|
||||
mocks.getVideoDuration.mockResolvedValue({
|
||||
duration: 5_000,
|
||||
size: 10 * 1024 * 1024
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the uploader stamped on the batch and each uploaded item', async () => {
|
||||
const webContents = createWebContents()
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
await dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 750,
|
||||
outputList: [
|
||||
{
|
||||
fileName: 'image.png',
|
||||
filePath: '/tmp/image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024,
|
||||
type: 'github'
|
||||
},
|
||||
{
|
||||
fileName: 'video.mp4',
|
||||
filePath: '/tmp/video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
size: 10 * 1024 * 1024,
|
||||
type: 's3'
|
||||
},
|
||||
{
|
||||
fileName: 'archive.zip',
|
||||
filePath: '/tmp/archive.zip',
|
||||
mimeType: 'application/zip',
|
||||
size: 2048,
|
||||
type: 'webdav'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const payloads = getTalkingDataPayloads(webContents)
|
||||
expect(getPayloadByEventId(payloads, 'upload').MapKv.type).toBe('github')
|
||||
expect(getPayloadByEventId(payloads, 'upload_image').MapKv.type).toBe('github')
|
||||
expect(getPayloadByEventId(payloads, 'upload_video').MapKv.type).toBe('s3')
|
||||
expect(getPayloadByEventId(payloads, 'upload_file').MapKv.type).toBe('webdav')
|
||||
expect(mocks.getConfig).not.toHaveBeenCalled()
|
||||
|
||||
for (const payload of payloads) {
|
||||
expect(payload.MapKv).not.toHaveProperty('configName')
|
||||
expect(payload.MapKv).not.toHaveProperty('configId')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the legacy root uploader only when output metadata has no uploader type', async () => {
|
||||
const webContents = createWebContents()
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
await dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 750,
|
||||
outputList: [{
|
||||
fileName: 'image.png',
|
||||
filePath: '/tmp/image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024
|
||||
}]
|
||||
})
|
||||
|
||||
const payloads = getTalkingDataPayloads(webContents)
|
||||
expect(getPayloadByEventId(payloads, 'upload').MapKv.type).toBe('root-default')
|
||||
expect(getPayloadByEventId(payloads, 'upload_image').MapKv.type).toBe('root-default')
|
||||
expect(mocks.getConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getConfig).toHaveBeenCalledWith('picBed.uploader')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ uploader: undefined, current: undefined, expected: 'picgo-cloud' },
|
||||
{ uploader: '', current: '', expected: 'picgo-cloud' },
|
||||
{ uploader: undefined, current: 'smms', expected: 'smms' },
|
||||
{ uploader: 'smms', current: 'picgo-cloud', expected: 'smms' }
|
||||
])('resolves the root fallback as $expected for uploader=$uploader current=$current', async ({ uploader, current, expected }) => {
|
||||
mocks.getConfig.mockImplementation((key: string) => {
|
||||
if (key === 'picBed.uploader') return uploader
|
||||
if (key === 'picBed.current') return current
|
||||
return undefined
|
||||
})
|
||||
const webContents = createWebContents()
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
await dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 100,
|
||||
outputList: [{ fileName: 'image.png', mimeType: 'image/png', size: 1024 }]
|
||||
})
|
||||
|
||||
const payloads = getTalkingDataPayloads(webContents)
|
||||
expect(getPayloadByEventId(payloads, 'upload').MapKv.type).toBe(expected)
|
||||
expect(getPayloadByEventId(payloads, 'upload_image').MapKv.type).toBe(expected)
|
||||
})
|
||||
|
||||
it('does not register or report after the origin window is destroyed during device initialization', async () => {
|
||||
const deviceId = createDeferred<string>()
|
||||
const webContents = createWebContents()
|
||||
mocks.deviceIdGetId.mockReturnValue(deviceId.promise)
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
const reportPromise = dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 100,
|
||||
outputList: [{
|
||||
fileName: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
type: 'github'
|
||||
}]
|
||||
})
|
||||
expect(mocks.deviceIdGetId).toHaveBeenCalledTimes(1)
|
||||
webContents.isDestroyed.mockReturnValue(true)
|
||||
deviceId.resolve('device-id')
|
||||
|
||||
await expect(reportPromise).resolves.toBeUndefined()
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses an item report when the origin window is destroyed during async metadata work', async () => {
|
||||
const metadata = createDeferred<{ duration: number, size: number }>()
|
||||
const webContents = createWebContents()
|
||||
mocks.getVideoDuration.mockReturnValue(metadata.promise)
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
const reportPromise = dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 100,
|
||||
outputList: [{
|
||||
fileName: 'video.mp4',
|
||||
filePath: '/tmp/video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
type: 's3'
|
||||
}]
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getVideoDuration).toHaveBeenCalledWith('/tmp/video.mp4')
|
||||
})
|
||||
webContents.isDestroyed.mockReturnValue(true)
|
||||
metadata.resolve({ duration: 5_000, size: 10 * 1024 * 1024 })
|
||||
|
||||
await expect(reportPromise).resolves.toBeUndefined()
|
||||
const payloads = getTalkingDataPayloads(webContents)
|
||||
expect(payloads.map(payload => payload.EventId)).toEqual(['upload'])
|
||||
expect(webContents.send).toHaveBeenCalledWith(REGISTER_DEVICE_ID, 'device-id')
|
||||
})
|
||||
|
||||
it('returns async metadata failures to the awaited reporting caller', async () => {
|
||||
const metadataError = new Error('metadata failed')
|
||||
const webContents = createWebContents()
|
||||
mocks.getVideoDuration.mockRejectedValue(metadataError)
|
||||
const { dataReportManager } = await import('../../main/utils/dataReport')
|
||||
|
||||
const reportPromise = dataReportManager.reportUploadData(asWebContents(webContents), {
|
||||
fromClipboard: false,
|
||||
duration: 100,
|
||||
outputList: [{
|
||||
fileName: 'video.mp4',
|
||||
filePath: '/tmp/video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
type: 's3'
|
||||
}]
|
||||
})
|
||||
|
||||
await expect(reportPromise).rejects.toBe(metadataError)
|
||||
expect(getTalkingDataPayloads(webContents).map(payload => payload.EventId)).toEqual(['upload'])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import type { UploadOptions } from 'picgo'
|
||||
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
|
||||
import {
|
||||
uploadClipboardFiles,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
|
||||
type WebContentsStub = {
|
||||
send: ReturnType<typeof vi.fn>
|
||||
isDestroyed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type WindowStub = {
|
||||
@@ -22,7 +24,6 @@ const mocks = vi.hoisted(() => {
|
||||
configValues,
|
||||
getConfigMock: vi.fn(),
|
||||
loggerInfoMock: vi.fn(),
|
||||
setWebContentsMock: vi.fn(),
|
||||
uploadMock: vi.fn(),
|
||||
uploadWithBuildInClipboardMock: vi.fn(),
|
||||
getAvailableWindowMock: vi.fn(),
|
||||
@@ -51,7 +52,8 @@ vi.mock('@core/picgo/logger', () => ({
|
||||
|
||||
vi.mock('../../main/apis/app/uploader', () => ({
|
||||
default: {
|
||||
setWebContents: mocks.setWebContentsMock
|
||||
upload: mocks.uploadMock,
|
||||
uploadWithBuildInClipboard: mocks.uploadWithBuildInClipboardMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -86,7 +88,8 @@ vi.mock('~/main/i18n/index', () => ({
|
||||
}))
|
||||
|
||||
const createWebContents = (): WebContentsStub => ({
|
||||
send: vi.fn()
|
||||
send: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
})
|
||||
|
||||
const asWebContents = (webContents: WebContentsStub): WebContents => {
|
||||
@@ -116,10 +119,6 @@ describe('main uploader API helpers', () => {
|
||||
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
|
||||
@@ -154,8 +153,10 @@ describe('main uploader API helpers', () => {
|
||||
|
||||
expect(result).toEqual([image])
|
||||
expect(mocks.loggerInfoMock).toHaveBeenCalledWith('upload clipboard file')
|
||||
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(availableWindow.webContents)
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith()
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith({
|
||||
webContents: availableWindow.webContents,
|
||||
options: undefined
|
||||
})
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
|
||||
expect(mocks.showNotificationMock).toHaveBeenCalledWith({
|
||||
title: 't:UPLOAD_SUCCEED',
|
||||
@@ -163,7 +164,7 @@ describe('main uploader API helpers', () => {
|
||||
})
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('clipboardFiles', [])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', [image])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPLOAD_COMPLETED, [image])
|
||||
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
|
||||
})
|
||||
|
||||
@@ -182,7 +183,26 @@ describe('main uploader API helpers', () => {
|
||||
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])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPLOAD_COMPLETED, [image])
|
||||
})
|
||||
|
||||
it('forwards clipboard options to the supplied origin window without consulting the fallback window', async () => {
|
||||
const originWebContents = createWebContents()
|
||||
const options: UploadOptions = {
|
||||
uploader: 'github',
|
||||
configName: 'Work',
|
||||
configId: 'github-work-id'
|
||||
}
|
||||
mocks.uploadMock.mockResolvedValue([])
|
||||
|
||||
const result = await uploadClipboardFilesWithInfo(options, asWebContents(originWebContents))
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mocks.getAvailableWindowMock).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith({
|
||||
webContents: originWebContents,
|
||||
options
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the builtin clipboard upload path when configured', async () => {
|
||||
@@ -191,14 +211,22 @@ describe('main uploader API helpers', () => {
|
||||
fileName: 'builtin.png',
|
||||
extname: '.png'
|
||||
}
|
||||
const options: UploadOptions = {
|
||||
uploader: 'smms',
|
||||
configName: 'Personal',
|
||||
configId: 'smms-personal-id'
|
||||
}
|
||||
mocks.configValues['settings.useBuiltinClipboard'] = true
|
||||
mocks.uploadWithBuildInClipboardMock.mockResolvedValue([image])
|
||||
|
||||
const result = await uploadClipboardFilesWithInfo()
|
||||
const result = await uploadClipboardFilesWithInfo(options)
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual([image])
|
||||
expect(mocks.uploadWithBuildInClipboardMock).toHaveBeenCalledWith()
|
||||
expect(mocks.uploadWithBuildInClipboardMock).toHaveBeenCalledWith({
|
||||
webContents: availableWindow.webContents,
|
||||
options
|
||||
})
|
||||
expect(mocks.uploadMock).not.toHaveBeenCalled()
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
})
|
||||
@@ -228,8 +256,11 @@ describe('main uploader API helpers', () => {
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual(images)
|
||||
expect(mocks.setWebContentsMock).toHaveBeenCalledWith(webContents)
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith(['/tmp/a.png', '/tmp/b.png'])
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith({
|
||||
input: ['/tmp/a.png', '/tmp/b.png'],
|
||||
webContents,
|
||||
options: undefined
|
||||
})
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith([
|
||||
`paste:${IPasteStyle.MARKDOWN}:${images[0].imgUrl}:$url`,
|
||||
`paste:${IPasteStyle.MARKDOWN}:${images[1].imgUrl}:$url`
|
||||
@@ -244,10 +275,58 @@ describe('main uploader API helpers', () => {
|
||||
})
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(1, images[0])
|
||||
expect(mocks.albumInsertMock).toHaveBeenNthCalledWith(2, images[1])
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith('uploadFiles', images)
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPLOAD_COMPLETED, images)
|
||||
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
|
||||
})
|
||||
|
||||
it('forwards selected-file options without changing the raw result or side effects', async () => {
|
||||
const webContents = createWebContents()
|
||||
const files: IFileWithPath[] = [{ path: '/tmp/a.png' }]
|
||||
const image: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/a image.png',
|
||||
fileName: 'a image.png',
|
||||
extname: '.png',
|
||||
origin: '/tmp/a.png'
|
||||
}
|
||||
const options: UploadOptions = {
|
||||
uploader: 'github',
|
||||
configName: 'Work',
|
||||
configId: 'github-work-id'
|
||||
}
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
|
||||
const result = await uploadSelectedFilesWithInfo(asWebContents(webContents), files, options)
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(result).toEqual([image])
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith({
|
||||
input: ['/tmp/a.png'],
|
||||
webContents,
|
||||
options
|
||||
})
|
||||
expect(mocks.handleCopyUrlMock).toHaveBeenCalledWith(`paste:${IPasteStyle.MARKDOWN}:${image.imgUrl}:$url`)
|
||||
expect(mocks.albumInsertMock).toHaveBeenCalledWith(image)
|
||||
expect(trayWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPLOAD_COMPLETED, [image])
|
||||
expect(settingWindow.webContents.send).toHaveBeenCalledWith(IRPCActionType.UPDATE_ALBUM)
|
||||
})
|
||||
|
||||
it('does not send post-upload updates to destroyed managed windows', async () => {
|
||||
const webContents = createWebContents()
|
||||
const image: ImgInfo = {
|
||||
imgUrl: 'https://raw.example/a.png',
|
||||
fileName: 'a.png'
|
||||
}
|
||||
trayWindow.webContents.isDestroyed.mockReturnValue(true)
|
||||
settingWindow.webContents.isDestroyed.mockReturnValue(true)
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
|
||||
await uploadSelectedFilesWithInfo(asWebContents(webContents), [{ path: '/tmp/a.png' }])
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(trayWindow.webContents.send).not.toHaveBeenCalled()
|
||||
expect(settingWindow.webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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' }]
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import type { UploadOptions } from 'picgo'
|
||||
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '~/universal/events/constants'
|
||||
|
||||
interface RenameContext {
|
||||
output: ImgInfo[]
|
||||
getConfig: <T>(name: string) => T
|
||||
}
|
||||
|
||||
interface RenamePlugin {
|
||||
handle: (context: RenameContext) => Promise<void>
|
||||
}
|
||||
|
||||
interface ReportOptions {
|
||||
fromClipboard: boolean
|
||||
duration: number
|
||||
outputList: ImgInfo[]
|
||||
}
|
||||
|
||||
type EventListener = (...args: unknown[]) => void
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const picgoListeners = new Map<string, Set<EventListener>>()
|
||||
const ipcListeners = new Map<string, Set<EventListener>>()
|
||||
let renameHandler: RenamePlugin['handle'] | undefined
|
||||
|
||||
const addListener = (listeners: Map<string, Set<EventListener>>, event: string, listener: EventListener) => {
|
||||
const eventListeners = listeners.get(event) ?? new Set<EventListener>()
|
||||
eventListeners.add(listener)
|
||||
listeners.set(event, eventListeners)
|
||||
}
|
||||
const removeListener = (listeners: Map<string, Set<EventListener>>, event: string, listener: EventListener) => {
|
||||
listeners.get(event)?.delete(listener)
|
||||
}
|
||||
const emit = (listeners: Map<string, Set<EventListener>>, event: string, ...args: unknown[]) => {
|
||||
for (const listener of [...(listeners.get(event) ?? [])]) {
|
||||
listener(...args)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadMock = vi.fn<(input?: IUploadOption, options?: UploadOptions) => Promise<ImgInfo[] | Error>>()
|
||||
const reportUploadDataMock = vi.fn<(webContents: WebContents, options: ReportOptions) => Promise<void>>()
|
||||
const privacyCheckMock = vi.fn<() => Promise<boolean>>()
|
||||
const getClipboardFilePathListMock = vi.fn<() => string[]>()
|
||||
const writeFileMock = vi.fn<(filePath: string, data: Buffer) => Promise<void>>()
|
||||
const unlinkMock = vi.fn<(filePath: string) => Promise<void>>()
|
||||
const readImageMock = vi.fn()
|
||||
const createWindowMock = vi.fn()
|
||||
const deleteWindowMock = vi.fn()
|
||||
|
||||
return {
|
||||
uploadMock,
|
||||
reportUploadDataMock,
|
||||
privacyCheckMock,
|
||||
getClipboardFilePathListMock,
|
||||
writeFileMock,
|
||||
unlinkMock,
|
||||
readImageMock,
|
||||
createWindowMock,
|
||||
deleteWindowMock,
|
||||
loggerErrorMock: vi.fn(),
|
||||
showNotificationMock: vi.fn(),
|
||||
picgo: {
|
||||
baseDir: '/picgo-base',
|
||||
upload: uploadMock,
|
||||
getConfig: vi.fn(),
|
||||
on: vi.fn((event: string, listener: EventListener) => {
|
||||
addListener(picgoListeners, event, listener)
|
||||
}),
|
||||
helper: {
|
||||
beforeUploadPlugins: {
|
||||
register: vi.fn((_name: string, plugin: RenamePlugin) => {
|
||||
renameHandler = plugin.handle
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
ipcMain: {
|
||||
on: vi.fn((event: string, listener: EventListener) => {
|
||||
addListener(ipcListeners, event, listener)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, listener: EventListener) => {
|
||||
removeListener(ipcListeners, event, listener)
|
||||
})
|
||||
},
|
||||
emitPicgo: (event: string, ...args: unknown[]) => {
|
||||
emit(picgoListeners, event, ...args)
|
||||
},
|
||||
emitIpc: (event: string, ...args: unknown[]) => {
|
||||
emit(ipcListeners, event, ...args)
|
||||
},
|
||||
addIpcListener: (event: string, listener: EventListener) => {
|
||||
addListener(ipcListeners, event, listener)
|
||||
},
|
||||
removeIpcListener: (event: string, listener: EventListener) => {
|
||||
removeListener(ipcListeners, event, listener)
|
||||
},
|
||||
ipcListenerCount: (event: string) => ipcListeners.get(event)?.size ?? 0,
|
||||
resetIpcListeners: () => ipcListeners.clear(),
|
||||
getRenameHandler: () => renameHandler
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
clipboard: {
|
||||
readImage: mocks.readImageMock
|
||||
},
|
||||
ipcMain: mocks.ipcMain
|
||||
}))
|
||||
|
||||
vi.mock('@core/picgo', () => ({
|
||||
default: mocks.picgo
|
||||
}))
|
||||
|
||||
vi.mock('@core/picgo/logger', () => ({
|
||||
default: {
|
||||
error: mocks.loggerErrorMock,
|
||||
info: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('apis/app/window/windowManager', () => ({
|
||||
default: {
|
||||
create: mocks.createWindowMock,
|
||||
deleteById: mocks.deleteWindowMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/common', () => ({
|
||||
getClipboardFilePathList: mocks.getClipboardFilePathListMock,
|
||||
showNotification: mocks.showNotificationMock
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/privacyManager', () => ({
|
||||
privacyManager: {
|
||||
check: mocks.privacyCheckMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/utils/dataReport', () => ({
|
||||
dataReportManager: {
|
||||
reportUploadData: mocks.reportUploadDataMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('~/main/i18n', () => ({
|
||||
T: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('write-file-atomic', () => ({
|
||||
default: mocks.writeFileMock
|
||||
}))
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
default: {
|
||||
unlink: mocks.unlinkMock
|
||||
}
|
||||
}))
|
||||
|
||||
import uploader from '~/main/apis/app/uploader'
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface WebContentsStub {
|
||||
id: number
|
||||
isDestroyed: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
setDestroyed: (destroyed: boolean) => void
|
||||
}
|
||||
|
||||
interface RenameWindowStub {
|
||||
id: number
|
||||
webContents: WebContentsStub
|
||||
close: ReturnType<typeof vi.fn>
|
||||
isDestroyed: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
removeListener: ReturnType<typeof vi.fn>
|
||||
emitClose: () => void
|
||||
}
|
||||
|
||||
const createDeferred = <T = void>(): Deferred<T> => {
|
||||
let resolvePromise: (value: T) => void = () => undefined
|
||||
let rejectPromise: (reason?: unknown) => void = () => undefined
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: resolvePromise,
|
||||
reject: rejectPromise
|
||||
}
|
||||
}
|
||||
|
||||
const createWebContents = (id: number): WebContentsStub => {
|
||||
let destroyed = false
|
||||
return {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
send: vi.fn(),
|
||||
setDestroyed: (nextDestroyed: boolean) => {
|
||||
destroyed = nextDestroyed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const asWebContents = (webContents: WebContentsStub): WebContents => {
|
||||
return webContents as unknown as WebContents
|
||||
}
|
||||
|
||||
const createRenameWindow = (id: number, webContentsId: number): RenameWindowStub => {
|
||||
const listeners = new Map<string, Set<EventListener>>()
|
||||
let destroyed = false
|
||||
const webContents = createWebContents(webContentsId)
|
||||
const window: RenameWindowStub = {
|
||||
id,
|
||||
webContents,
|
||||
close: vi.fn(() => {
|
||||
destroyed = true
|
||||
for (const listener of [...(listeners.get('close') ?? [])]) {
|
||||
listener()
|
||||
}
|
||||
}),
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
on: vi.fn((event: string, listener: EventListener) => {
|
||||
const eventListeners = listeners.get(event) ?? new Set<EventListener>()
|
||||
eventListeners.add(listener)
|
||||
listeners.set(event, eventListeners)
|
||||
return window
|
||||
}),
|
||||
removeListener: vi.fn((event: string, listener: EventListener) => {
|
||||
listeners.get(event)?.delete(listener)
|
||||
return window
|
||||
}),
|
||||
emitClose: () => {
|
||||
for (const listener of [...(listeners.get('close') ?? [])]) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
const createImage = (name: string): ImgInfo => ({
|
||||
fileName: name,
|
||||
extname: '.png',
|
||||
imgUrl: `https://example.test/${name}`
|
||||
})
|
||||
|
||||
const createRenameContext = (output: ImgInfo[]): RenameContext => ({
|
||||
output,
|
||||
getConfig: <T>(name: string): T => (name === 'settings.rename') as T
|
||||
})
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('Uploader task context', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mocks.resetIpcListeners()
|
||||
mocks.uploadMock.mockReset()
|
||||
mocks.reportUploadDataMock.mockReset()
|
||||
mocks.reportUploadDataMock.mockResolvedValue(undefined)
|
||||
mocks.privacyCheckMock.mockReset()
|
||||
mocks.privacyCheckMock.mockResolvedValue(true)
|
||||
mocks.getClipboardFilePathListMock.mockReset()
|
||||
mocks.getClipboardFilePathListMock.mockReturnValue([])
|
||||
mocks.writeFileMock.mockReset()
|
||||
mocks.writeFileMock.mockResolvedValue(undefined)
|
||||
mocks.unlinkMock.mockReset()
|
||||
mocks.unlinkMock.mockResolvedValue(undefined)
|
||||
mocks.readImageMock.mockReset()
|
||||
mocks.createWindowMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps interleaved progress and reports bound to the originating window and suppresses late events', async () => {
|
||||
const progressA = createDeferred()
|
||||
const progressB = createDeferred()
|
||||
const finishA = createDeferred()
|
||||
const finishB = createDeferred()
|
||||
const lateEvent = createDeferred()
|
||||
const imageA = createImage('a.png')
|
||||
const imageB = createImage('b.png')
|
||||
const windowA = createWebContents(1)
|
||||
const windowB = createWebContents(2)
|
||||
const optionsA: UploadOptions = { uploader: 'github', configId: 'a-id' }
|
||||
const optionsB: UploadOptions = { uploader: 's3', configName: 'B' }
|
||||
let lateEventPromise: Promise<void> | undefined
|
||||
|
||||
mocks.uploadMock
|
||||
.mockImplementationOnce(async () => {
|
||||
await progressA.promise
|
||||
mocks.emitPicgo('uploadProgress', 25)
|
||||
lateEventPromise = lateEvent.promise.then(() => {
|
||||
mocks.emitPicgo('uploadProgress', 99)
|
||||
})
|
||||
await finishA.promise
|
||||
return [imageA]
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
await progressB.promise
|
||||
mocks.emitPicgo('uploadProgress', 50)
|
||||
await finishB.promise
|
||||
return [imageB]
|
||||
})
|
||||
|
||||
const uploadA = uploader.upload({
|
||||
input: ['/a.png'],
|
||||
webContents: asWebContents(windowA),
|
||||
options: optionsA
|
||||
})
|
||||
const uploadB = uploader.upload({
|
||||
input: ['/b.png'],
|
||||
webContents: asWebContents(windowB),
|
||||
options: optionsB
|
||||
})
|
||||
|
||||
progressB.resolve()
|
||||
await flushPromises()
|
||||
progressA.resolve()
|
||||
await flushPromises()
|
||||
|
||||
expect(windowA.send).toHaveBeenCalledWith('uploadProgress', 25)
|
||||
expect(windowA.send).not.toHaveBeenCalledWith('uploadProgress', 50)
|
||||
expect(windowB.send).toHaveBeenCalledWith('uploadProgress', 50)
|
||||
expect(windowB.send).not.toHaveBeenCalledWith('uploadProgress', 25)
|
||||
|
||||
finishB.resolve()
|
||||
await expect(uploadB).resolves.toEqual([imageB])
|
||||
finishA.resolve()
|
||||
await expect(uploadA).resolves.toEqual([imageA])
|
||||
|
||||
expect(mocks.uploadMock).toHaveBeenNthCalledWith(1, ['/a.png'], optionsA)
|
||||
expect(mocks.uploadMock).toHaveBeenNthCalledWith(2, ['/b.png'], optionsB)
|
||||
expect(mocks.reportUploadDataMock).toHaveBeenCalledWith(asWebContents(windowA), expect.objectContaining({
|
||||
outputList: [imageA]
|
||||
}))
|
||||
expect(mocks.reportUploadDataMock).toHaveBeenCalledWith(asWebContents(windowB), expect.objectContaining({
|
||||
outputList: [imageB]
|
||||
}))
|
||||
|
||||
lateEvent.resolve()
|
||||
await lateEventPromise
|
||||
mocks.emitPicgo('uploadProgress', 100)
|
||||
|
||||
expect(windowA.send).not.toHaveBeenCalledWith('uploadProgress', 99)
|
||||
expect(windowA.send).not.toHaveBeenCalledWith('uploadProgress', 100)
|
||||
expect(windowB.send).not.toHaveBeenCalledWith('uploadProgress', 99)
|
||||
expect(windowB.send).not.toHaveBeenCalledWith('uploadProgress', 100)
|
||||
})
|
||||
|
||||
it('cleans a failed task rename listeners without breaking another dialog or unrelated listeners', async () => {
|
||||
const renameHandler = mocks.getRenameHandler()
|
||||
expect(renameHandler).toBeDefined()
|
||||
if (!renameHandler) return
|
||||
|
||||
const failedWindow = createRenameWindow(10, 110)
|
||||
const survivingWindow = createRenameWindow(20, 120)
|
||||
const failedImages = [createImage('failed-a.png'), createImage('failed-b.png')]
|
||||
const survivingImage = createImage('surviving.png')
|
||||
const taskWindowA = createWebContents(11)
|
||||
const taskWindowB = createWebContents(12)
|
||||
const unrelatedReadyListener = vi.fn()
|
||||
mocks.addIpcListener(GET_RENAME_FILE_NAME, unrelatedReadyListener)
|
||||
mocks.createWindowMock
|
||||
.mockReturnValueOnce(failedWindow)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(survivingWindow)
|
||||
mocks.uploadMock
|
||||
.mockImplementationOnce(async () => {
|
||||
await renameHandler(createRenameContext(failedImages))
|
||||
return failedImages
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
await renameHandler(createRenameContext([survivingImage]))
|
||||
mocks.emitPicgo('uploadProgress', 75)
|
||||
return [survivingImage]
|
||||
})
|
||||
|
||||
const failedUpload = uploader.upload({ webContents: asWebContents(taskWindowA) })
|
||||
const survivingUpload = uploader.upload({ webContents: asWebContents(taskWindowB) })
|
||||
await flushPromises()
|
||||
|
||||
await expect(failedUpload).resolves.toBe(false)
|
||||
expect(mocks.ipcListenerCount(`${RENAME_FILE_NAME}${failedWindow.webContents.id}`)).toBe(0)
|
||||
expect(mocks.ipcListenerCount(`${RENAME_FILE_NAME}${survivingWindow.webContents.id}`)).toBe(1)
|
||||
expect(mocks.ipcListenerCount(GET_RENAME_FILE_NAME)).toBe(2)
|
||||
|
||||
mocks.emitIpc(GET_RENAME_FILE_NAME, { sender: { id: survivingWindow.webContents.id } })
|
||||
expect(survivingWindow.webContents.send).toHaveBeenCalledWith(
|
||||
RENAME_FILE_NAME,
|
||||
'surviving.png',
|
||||
'surviving.png',
|
||||
survivingWindow.webContents.id
|
||||
)
|
||||
mocks.emitIpc(
|
||||
`${RENAME_FILE_NAME}${survivingWindow.webContents.id}`,
|
||||
{ sender: { id: survivingWindow.webContents.id } },
|
||||
'renamed.png'
|
||||
)
|
||||
|
||||
await expect(survivingUpload).resolves.toEqual([
|
||||
expect.objectContaining({ fileName: 'renamed.png' })
|
||||
])
|
||||
expect(taskWindowB.send).toHaveBeenCalledWith('uploadProgress', 75)
|
||||
expect(mocks.ipcListenerCount(`${RENAME_FILE_NAME}${survivingWindow.webContents.id}`)).toBe(0)
|
||||
expect(mocks.ipcListenerCount(GET_RENAME_FILE_NAME)).toBe(1)
|
||||
expect(unrelatedReadyListener).toHaveBeenCalledTimes(1)
|
||||
|
||||
mocks.removeIpcListener(GET_RENAME_FILE_NAME, unrelatedReadyListener)
|
||||
})
|
||||
|
||||
it('skips progress and reporting when the captured window is destroyed', async () => {
|
||||
const continueUpload = createDeferred()
|
||||
const window = createWebContents(3)
|
||||
const image = createImage('destroyed.png')
|
||||
mocks.uploadMock.mockImplementation(async () => {
|
||||
await continueUpload.promise
|
||||
mocks.emitPicgo('uploadProgress', 40)
|
||||
return [image]
|
||||
})
|
||||
|
||||
const upload = uploader.upload({
|
||||
input: ['/destroyed.png'],
|
||||
webContents: asWebContents(window)
|
||||
})
|
||||
window.setDestroyed(true)
|
||||
continueUpload.resolve()
|
||||
|
||||
await expect(upload).resolves.toEqual([image])
|
||||
expect(window.send).not.toHaveBeenCalled()
|
||||
expect(mocks.reportUploadDataMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail a successful upload when reporting rejects', async () => {
|
||||
const window = createWebContents(4)
|
||||
const image = createImage('report-error.png')
|
||||
const reportError = new Error('report failed')
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
mocks.reportUploadDataMock.mockRejectedValue(reportError)
|
||||
|
||||
await expect(uploader.upload({
|
||||
input: ['/report-error.png'],
|
||||
webContents: asWebContents(window)
|
||||
})).resolves.toEqual([image])
|
||||
expect(mocks.loggerErrorMock).toHaveBeenCalledWith(reportError)
|
||||
})
|
||||
|
||||
it('forwards options for the Core clipboard path and preserves default uploads', async () => {
|
||||
const window = createWebContents(5)
|
||||
const clipboardImage = createImage('core-clipboard.png')
|
||||
const defaultImage = createImage('default.png')
|
||||
const options: UploadOptions = { uploader: 'github', configName: 'Work' }
|
||||
mocks.uploadMock
|
||||
.mockResolvedValueOnce([clipboardImage])
|
||||
.mockResolvedValueOnce([defaultImage])
|
||||
|
||||
await expect(uploader.upload({
|
||||
webContents: asWebContents(window),
|
||||
options
|
||||
})).resolves.toEqual([clipboardImage])
|
||||
await expect(uploader.upload()).resolves.toEqual([defaultImage])
|
||||
|
||||
expect(mocks.uploadMock).toHaveBeenNthCalledWith(1, undefined, options)
|
||||
expect(mocks.uploadMock).toHaveBeenNthCalledWith(2, undefined, undefined)
|
||||
expect(mocks.reportUploadDataMock).toHaveBeenCalledWith(asWebContents(window), expect.objectContaining({
|
||||
fromClipboard: true,
|
||||
outputList: [clipboardImage]
|
||||
}))
|
||||
})
|
||||
|
||||
it('forwards options and window ownership for existing clipboard file paths without deleting them', async () => {
|
||||
const window = createWebContents(6)
|
||||
const image = createImage('existing-clipboard.png')
|
||||
const options: UploadOptions = { uploader: 's3', configId: 'existing-id' }
|
||||
mocks.getClipboardFilePathListMock.mockReturnValue(['/existing/clipboard.png'])
|
||||
mocks.uploadMock.mockImplementation(async () => {
|
||||
mocks.emitPicgo('uploadProgress', 60)
|
||||
return [image]
|
||||
})
|
||||
|
||||
await expect(uploader.uploadWithBuildInClipboard({
|
||||
webContents: asWebContents(window),
|
||||
options
|
||||
})).resolves.toEqual([image])
|
||||
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith(['/existing/clipboard.png'], options)
|
||||
expect(window.send).toHaveBeenCalledWith('uploadProgress', 60)
|
||||
expect(mocks.reportUploadDataMock).toHaveBeenCalledWith(asWebContents(window), expect.any(Object))
|
||||
expect(mocks.writeFileMock).not.toHaveBeenCalled()
|
||||
expect(mocks.unlinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits cleanup of generated clipboard images while forwarding task options and ownership', async () => {
|
||||
const window = createWebContents(7)
|
||||
const image = createImage('generated-clipboard.png')
|
||||
const options: UploadOptions = { uploader: 'github', configId: 'generated-id' }
|
||||
const unlink = createDeferred()
|
||||
const unlinkStarted = createDeferred()
|
||||
let uploadSettled = false
|
||||
mocks.readImageMock.mockReturnValue({
|
||||
isEmpty: () => false,
|
||||
toPNG: () => Buffer.from('png')
|
||||
})
|
||||
mocks.uploadMock.mockResolvedValue([image])
|
||||
mocks.unlinkMock.mockImplementation(async () => {
|
||||
unlinkStarted.resolve()
|
||||
await unlink.promise
|
||||
})
|
||||
|
||||
const uploadPromise = uploader.uploadWithBuildInClipboard({
|
||||
webContents: asWebContents(window),
|
||||
options
|
||||
})
|
||||
const trackedUpload = uploadPromise.then((result) => {
|
||||
uploadSettled = true
|
||||
return result
|
||||
})
|
||||
await unlinkStarted.promise
|
||||
|
||||
expect(mocks.writeFileMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/picgo-base\/.*\/\d{17}\.png$/),
|
||||
Buffer.from('png')
|
||||
)
|
||||
const generatedPath = mocks.writeFileMock.mock.calls[0][0]
|
||||
expect(mocks.uploadMock).toHaveBeenCalledWith([generatedPath], options)
|
||||
expect(mocks.unlinkMock).toHaveBeenCalledWith(generatedPath)
|
||||
expect(uploadSettled).toBe(false)
|
||||
|
||||
unlink.resolve()
|
||||
await expect(trackedUpload).resolves.toEqual([image])
|
||||
expect(uploadSettled).toBe(true)
|
||||
expect(mocks.reportUploadDataMock).toHaveBeenCalledWith(asWebContents(window), expect.any(Object))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { providerMockApi } from '@/components/main/providers/provider-mock'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
|
||||
async function settleMockRequest<T> (request: Promise<T>): Promise<T> {
|
||||
await vi.runAllTimersAsync()
|
||||
return request
|
||||
}
|
||||
|
||||
describe('provider mock defaults', () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
await settleMockRequest(providerMockApi.resetMockState())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses PicGo Cloud by default while retaining SM.MS as a provider', async () => {
|
||||
const snapshot = await settleMockRequest(providerMockApi.getProviderAppState())
|
||||
|
||||
expect(snapshot.appConfig.picBed.uploader).toBe(PICGO_CLOUD_UPLOADER_TYPE)
|
||||
expect(snapshot.appConfig.picBed.current).toBe(PICGO_CLOUD_UPLOADER_TYPE)
|
||||
expect(snapshot.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: PICGO_CLOUD_UPLOADER_TYPE,
|
||||
isDefaultUploader: true
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'smms',
|
||||
isDefaultUploader: false
|
||||
})
|
||||
]))
|
||||
})
|
||||
|
||||
it('falls back to PicGo Cloud after removing the active external uploader', async () => {
|
||||
const externalUploaderId = 'external-uploader'
|
||||
|
||||
await settleMockRequest(providerMockApi.upsertExternalUploader({
|
||||
id: externalUploaderId,
|
||||
name: 'External uploader',
|
||||
schema: [],
|
||||
configState: {
|
||||
defaultId: '',
|
||||
configList: []
|
||||
}
|
||||
}))
|
||||
await settleMockRequest(providerMockApi.setDefaultUploader(externalUploaderId))
|
||||
|
||||
const result = await settleMockRequest(providerMockApi.removeUploader(externalUploaderId))
|
||||
|
||||
expect(result).toEqual({
|
||||
removed: true,
|
||||
fallbackUploaderId: PICGO_CLOUD_UPLOADER_TYPE
|
||||
})
|
||||
expect(await settleMockRequest(providerMockApi.getFirstAvailableUploaderId())).toBe(PICGO_CLOUD_UPLOADER_TYPE)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IConfig } from 'picgo'
|
||||
import { getConfig, getPicBeds } from '@/utils/dataSender'
|
||||
import { initialAppStoreState } from '@/store/app-store'
|
||||
import {
|
||||
appActions,
|
||||
PicGoCloudLoginStatusValues,
|
||||
@@ -17,7 +18,7 @@ vi.mock('@/utils/dataSender', () => {
|
||||
|
||||
const resetStore = () => {
|
||||
useStore.setState({
|
||||
defaultPicBed: 'smms',
|
||||
defaultPicBed: initialAppStoreState.defaultPicBed,
|
||||
appConfig: null,
|
||||
picBeds: [],
|
||||
picgoCloud: {
|
||||
@@ -56,6 +57,32 @@ describe('renderer/store appConfig', () => {
|
||||
expect(nextState.defaultPicBed).toBe('github')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ uploader: '', current: undefined, expected: 'picgo-cloud' },
|
||||
{ uploader: '', current: '', expected: 'picgo-cloud' },
|
||||
{ uploader: '', current: 'smms', expected: 'smms' },
|
||||
{ uploader: 'smms', current: 'picgo-cloud', expected: 'smms' }
|
||||
])('hydrates the uploader as $expected for uploader=$uploader current=$current', async ({ uploader, current, expected }) => {
|
||||
const config: IConfig = { picBed: { uploader, current }, picgoPlugins: {} }
|
||||
getConfigMock.mockResolvedValue(config)
|
||||
getPicBedsMock.mockResolvedValue([])
|
||||
|
||||
await appActions.hydrateAppState()
|
||||
|
||||
expect(useStore.getState().defaultPicBed).toBe(expected)
|
||||
expect(config.picBed).toEqual({ uploader, current })
|
||||
})
|
||||
|
||||
it('uses PicGo Cloud while configuration is unavailable', async () => {
|
||||
expect(useStore.getState().defaultPicBed).toBe('picgo-cloud')
|
||||
useStore.setState({ defaultPicBed: 'github' })
|
||||
getConfigMock.mockResolvedValue(undefined)
|
||||
|
||||
await appActions.refreshAppConfig()
|
||||
|
||||
expect(useStore.getState().defaultPicBed).toBe('picgo-cloud')
|
||||
})
|
||||
|
||||
it('refreshPicBeds updates picBeds', async () => {
|
||||
const picBeds: IPicBedType[] = [
|
||||
{ type: 'smms', name: 'SM.MS', visible: true },
|
||||
|
||||
@@ -219,9 +219,11 @@ export function createTray () {
|
||||
tray.on('drop-files', async (event, files: string[]) => {
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)!
|
||||
const imgs = await uploader
|
||||
.setWebContents(trayWindow.webContents)
|
||||
.upload(files)
|
||||
const webContents = trayWindow.webContents
|
||||
const imgs = await uploader.upload({
|
||||
input: files,
|
||||
webContents
|
||||
})
|
||||
if (imgs !== false) {
|
||||
const pasteText: string[] = []
|
||||
for (let i = 0; i < imgs.length; i++) {
|
||||
@@ -238,7 +240,7 @@ export function createTray () {
|
||||
await AlbumDB.getInstance().insert(imgs[i])
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
trayWindow.webContents.send('dragFiles', imgs)
|
||||
if (!webContents.isDestroyed()) webContents.send('dragFiles', imgs)
|
||||
}
|
||||
})
|
||||
// toggleWindow()
|
||||
|
||||
@@ -8,23 +8,23 @@ import { handleCopyUrl, handleUrlEncodeWithSetting, showNotification } from '~/m
|
||||
import { T } from '~/main/i18n/index'
|
||||
import logger from '@core/picgo/logger'
|
||||
import picgo from '@core/picgo'
|
||||
import type { UploadOptions } from 'picgo'
|
||||
// import dayjs from 'dayjs'
|
||||
|
||||
const handleClipboardUploading = async (): Promise<false | ImgInfo[]> => {
|
||||
const handleClipboardUploading = async (options?: UploadOptions, webContents?: WebContents): Promise<false | ImgInfo[]> => {
|
||||
const useBuiltinClipboard = !!picgo.getConfig<boolean>('settings.useBuiltinClipboard')
|
||||
const win = windowManager.getAvailableWindow()
|
||||
const uploadWebContents = webContents ?? windowManager.getAvailableWindow().webContents
|
||||
if (useBuiltinClipboard) {
|
||||
return await uploader.setWebContents(win!.webContents).uploadWithBuildInClipboard()
|
||||
return await uploader.uploadWithBuildInClipboard({ webContents: uploadWebContents, options })
|
||||
}
|
||||
return await uploader.setWebContents(win!.webContents).upload()
|
||||
return await uploader.upload({ webContents: uploadWebContents, options })
|
||||
}
|
||||
|
||||
export const uploadClipboardFilesWithInfo = async (): Promise<ImgInfo[]> => {
|
||||
export const uploadClipboardFilesWithInfo = async (options?: UploadOptions, webContents?: WebContents): Promise<ImgInfo[]> => {
|
||||
logger.info('upload clipboard file')
|
||||
const img = await handleClipboardUploading()
|
||||
const img = await handleClipboardUploading(options, webContents)
|
||||
if (img !== false) {
|
||||
if (img.length > 0) {
|
||||
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
handleCopyUrl(pasteTemplate(pasteStyle, img[0], picgo.getConfig<string>('settings.customLink')))
|
||||
setTimeout(() => {
|
||||
@@ -36,10 +36,16 @@ export const uploadClipboardFilesWithInfo = async (): Promise<ImgInfo[]> => {
|
||||
}, 100)
|
||||
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)
|
||||
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
|
||||
if (trayWindow && !trayWindow.webContents.isDestroyed()) {
|
||||
trayWindow.webContents.send('clipboardFiles', [])
|
||||
trayWindow.webContents.send(IRPCActionType.UPLOAD_COMPLETED, img)
|
||||
}
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)
|
||||
if (settingWindow && !settingWindow.webContents.isDestroyed()) {
|
||||
settingWindow.webContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
}
|
||||
return img
|
||||
} else {
|
||||
@@ -54,14 +60,14 @@ export const uploadClipboardFilesWithInfo = async (): Promise<ImgInfo[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadClipboardFiles = async (): Promise<string> => {
|
||||
const img = await uploadClipboardFilesWithInfo()
|
||||
export const uploadClipboardFiles = async (options?: UploadOptions, webContents?: WebContents): Promise<string> => {
|
||||
const img = await uploadClipboardFilesWithInfo(options, webContents)
|
||||
return img[0]?.imgUrl ? handleUrlEncodeWithSetting(img[0].imgUrl) : ''
|
||||
}
|
||||
|
||||
export const uploadSelectedFilesWithInfo = async (webContents: WebContents, files: IFileWithPath[]): Promise<ImgInfo[]> => {
|
||||
export const uploadSelectedFilesWithInfo = async (webContents: WebContents, files: IFileWithPath[], options?: UploadOptions): Promise<ImgInfo[]> => {
|
||||
const input = files.map(item => item.path)
|
||||
const imgs = await uploader.setWebContents(webContents).upload(input)
|
||||
const imgs = await uploader.upload({ input, webContents, options })
|
||||
if (imgs !== false) {
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
const pasteText: string[] = []
|
||||
@@ -78,9 +84,15 @@ export const uploadSelectedFilesWithInfo = async (webContents: WebContents, file
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
// trayWindow just be created in mac/windows, not in linux
|
||||
windowManager.get(IWindowList.TRAY_WINDOW)?.webContents?.send('uploadFiles', imgs)
|
||||
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
|
||||
if (trayWindow && !trayWindow.webContents.isDestroyed()) {
|
||||
trayWindow.webContents.send(IRPCActionType.UPLOAD_COMPLETED, imgs)
|
||||
}
|
||||
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
|
||||
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)
|
||||
if (settingWindow && !settingWindow.webContents.isDestroyed()) {
|
||||
settingWindow.webContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
}
|
||||
return imgs
|
||||
} else {
|
||||
@@ -88,8 +100,8 @@ export const uploadSelectedFilesWithInfo = async (webContents: WebContents, file
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[]): Promise<string[]> => {
|
||||
const imgs = await uploadSelectedFilesWithInfo(webContents, files)
|
||||
export const uploadSelectedFiles = async (webContents: WebContents, files: IFileWithPath[], options?: UploadOptions): Promise<string[]> => {
|
||||
const imgs = await uploadSelectedFilesWithInfo(webContents, files, options)
|
||||
return imgs
|
||||
.map(item => item.imgUrl)
|
||||
.filter((url): url is string => typeof url === 'string' && url !== '')
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
WebContents,
|
||||
clipboard
|
||||
} from 'electron'
|
||||
import type { IpcMainEvent, WebContents } from 'electron'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import dayjs from 'dayjs'
|
||||
import picgo from '@core/picgo'
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
@@ -19,27 +20,82 @@ 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 { IpcMainEvent } from 'electron/main'
|
||||
import { dataReportManager } from '~/main/utils/dataReport'
|
||||
import type { UploadTask } from './types'
|
||||
|
||||
const waitForRename = (window: BrowserWindow, id: number): Promise<string|null> => {
|
||||
interface UploadTaskContext {
|
||||
active: boolean
|
||||
renameCleanups: Set<() => void>
|
||||
task: UploadTask
|
||||
}
|
||||
|
||||
const isAvailableWebContents = (webContents: WebContents | undefined): webContents is WebContents => {
|
||||
return webContents !== undefined && !webContents.isDestroyed()
|
||||
}
|
||||
|
||||
const waitForRename = (
|
||||
window: BrowserWindow,
|
||||
fileName: string | undefined,
|
||||
originalFileName: string | undefined,
|
||||
context: UploadTaskContext | undefined
|
||||
): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const windowId = window.id
|
||||
ipcMain.once(`${RENAME_FILE_NAME}${id}`, (evt: IpcMainEvent, newName: string) => {
|
||||
resolve(newName)
|
||||
window.close()
|
||||
})
|
||||
window.on('close', () => {
|
||||
resolve(null)
|
||||
ipcMain.removeAllListeners(`${RENAME_FILE_NAME}${id}`)
|
||||
const replyChannel = `${RENAME_FILE_NAME}${window.webContents.id}`
|
||||
let settled = false
|
||||
let windowDeleted = false
|
||||
|
||||
const deleteWindow = () => {
|
||||
if (windowDeleted) return
|
||||
windowDeleted = true
|
||||
windowManager.deleteById(windowId)
|
||||
})
|
||||
}
|
||||
const cleanup = () => {
|
||||
ipcMain.removeListener(GET_RENAME_FILE_NAME, handleReady)
|
||||
ipcMain.removeListener(replyChannel, handleReply)
|
||||
window.removeListener('close', handleClose)
|
||||
context?.renameCleanups.delete(cancel)
|
||||
}
|
||||
const finish = (name: string | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(name)
|
||||
}
|
||||
const closeWindow = () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close()
|
||||
}
|
||||
deleteWindow()
|
||||
}
|
||||
function handleReady (evt: IpcMainEvent) {
|
||||
if (evt.sender.id === window.webContents.id) {
|
||||
logger.info('rename window ready, wait for rename...')
|
||||
window.webContents.send(RENAME_FILE_NAME, fileName, originalFileName, window.webContents.id)
|
||||
}
|
||||
}
|
||||
function handleReply (_evt: IpcMainEvent, newName: string) {
|
||||
finish(newName)
|
||||
closeWindow()
|
||||
}
|
||||
function handleClose () {
|
||||
finish(null)
|
||||
deleteWindow()
|
||||
}
|
||||
function cancel () {
|
||||
finish(null)
|
||||
closeWindow()
|
||||
}
|
||||
|
||||
ipcMain.on(GET_RENAME_FILE_NAME, handleReady)
|
||||
ipcMain.on(replyChannel, handleReply)
|
||||
window.on('close', handleClose)
|
||||
context?.renameCleanups.add(cancel)
|
||||
})
|
||||
}
|
||||
|
||||
class Uploader {
|
||||
private webContents: WebContents | null = null
|
||||
// private uploading: boolean = false
|
||||
private readonly uploadTaskStorage = new AsyncLocalStorage<UploadTaskContext>()
|
||||
constructor () {
|
||||
this.init()
|
||||
}
|
||||
@@ -50,8 +106,14 @@ class Uploader {
|
||||
showNotification(message)
|
||||
})
|
||||
|
||||
picgo.on('uploadProgress', (progress: any) => {
|
||||
this.webContents?.send('uploadProgress', progress)
|
||||
picgo.on('uploadProgress', (progress: unknown) => {
|
||||
const context = this.uploadTaskStorage.getStore()
|
||||
if (!context?.active || !isAvailableWebContents(context.task.webContents)) return
|
||||
try {
|
||||
context.task.webContents.send('uploadProgress', progress)
|
||||
} catch (error: unknown) {
|
||||
logger.error(error)
|
||||
}
|
||||
})
|
||||
picgo.on('beforeTransform', () => {
|
||||
if (picgo.getConfig<boolean>('settings.uploadNotification')) {
|
||||
@@ -63,8 +125,8 @@ class Uploader {
|
||||
})
|
||||
picgo.helper.beforeUploadPlugins.register('renameFn', {
|
||||
handle: async (ctx: IPicGo) => {
|
||||
const rename = picgo.getConfig<boolean>('settings.rename')
|
||||
const autoRename = picgo.getConfig<boolean>('settings.autoRename')
|
||||
const rename = ctx.getConfig<boolean>('settings.rename')
|
||||
const autoRename = ctx.getConfig<boolean>('settings.autoRename')
|
||||
if (autoRename || rename) {
|
||||
await Promise.all(ctx.output.map(async (item, index) => {
|
||||
let name: undefined | string | null
|
||||
@@ -77,13 +139,12 @@ class Uploader {
|
||||
if (rename) {
|
||||
const window = windowManager.create(IWindowList.RENAME_WINDOW)!
|
||||
logger.info('wait for rename window ready...')
|
||||
ipcMain.on(GET_RENAME_FILE_NAME, (evt) => {
|
||||
if (evt.sender.id === window.webContents.id) {
|
||||
logger.info('rename window ready, wait for rename...')
|
||||
window.webContents.send(RENAME_FILE_NAME, fileName, item.fileName, window.webContents.id)
|
||||
}
|
||||
})
|
||||
name = await waitForRename(window, window.webContents.id)
|
||||
name = await waitForRename(
|
||||
window,
|
||||
fileName,
|
||||
item.fileName,
|
||||
this.uploadTaskStorage.getStore()
|
||||
)
|
||||
}
|
||||
item.fileName = name || fileName
|
||||
}))
|
||||
@@ -92,15 +153,18 @@ class Uploader {
|
||||
})
|
||||
}
|
||||
|
||||
setWebContents (webContents: WebContents) {
|
||||
this.webContents = webContents
|
||||
return this
|
||||
private cleanupRenameListeners (context: UploadTaskContext) {
|
||||
for (const cleanup of [...context.renameCleanups]) {
|
||||
cleanup()
|
||||
}
|
||||
context.renameCleanups.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* use electron's clipboard image to upload
|
||||
*/
|
||||
async uploadWithBuildInClipboard (): Promise<ImgInfo[]|false> {
|
||||
async uploadWithBuildInClipboard (task: Omit<UploadTask, 'input'> = {}): Promise<ImgInfo[] | false> {
|
||||
const clipboardTask = { ...task, options: task.options && { ...task.options } }
|
||||
let filePath = ''
|
||||
try {
|
||||
const imgPath = getClipboardFilePathList()
|
||||
@@ -114,54 +178,72 @@ class Uploader {
|
||||
const fileName = `${dayjs().format('YYYYMMDDHHmmssSSS')}.png`
|
||||
filePath = path.join(baseDir, CLIPBOARD_IMAGE_FOLDER, fileName)
|
||||
await writeFile(filePath, buffer)
|
||||
return await this.upload([filePath])
|
||||
return await this.upload({ ...clipboardTask, input: [filePath] })
|
||||
} else {
|
||||
return await this.upload(imgPath)
|
||||
return await this.upload({ ...clipboardTask, input: imgPath })
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
logger.error(e)
|
||||
return false
|
||||
} finally {
|
||||
if (filePath) {
|
||||
fse.unlink(filePath)
|
||||
try {
|
||||
await fse.unlink(filePath)
|
||||
} catch (e: unknown) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async upload (img?: IUploadOption): Promise<ImgInfo[]|false> {
|
||||
try {
|
||||
const privacyCheckRes = await privacyManager.check()
|
||||
if (!privacyCheckRes) {
|
||||
throw Error(T('PRIVACY_TIPS'))
|
||||
}
|
||||
const startTime = Date.now()
|
||||
const output = await picgo.upload(img)
|
||||
if (Array.isArray(output) && output.some((item: ImgInfo) => item.imgUrl)) {
|
||||
if (this.webContents) {
|
||||
dataReportManager.reportUploadData(this.webContents, {
|
||||
fromClipboard: !img,
|
||||
duration: Date.now() - startTime,
|
||||
outputList: output
|
||||
})
|
||||
}
|
||||
return output.filter(item => item.imgUrl)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e)
|
||||
setTimeout(() => {
|
||||
showNotification({
|
||||
title: T('UPLOAD_FAILED'),
|
||||
body: util.format(e.stack),
|
||||
clickToCopy: true
|
||||
})
|
||||
}, 500)
|
||||
return false
|
||||
} finally {
|
||||
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
|
||||
async upload (task: UploadTask = {}): Promise<ImgInfo[] | false> {
|
||||
// Capture the caller's values before any await; each task keeps its original window.
|
||||
const capturedTask: UploadTask = { ...task, options: task.options && { ...task.options } }
|
||||
const context: UploadTaskContext = {
|
||||
active: true,
|
||||
renameCleanups: new Set(),
|
||||
task: capturedTask
|
||||
}
|
||||
return await this.uploadTaskStorage.run(context, async () => {
|
||||
try {
|
||||
const privacyCheckRes = await privacyManager.check()
|
||||
if (!privacyCheckRes) {
|
||||
throw Error(T('PRIVACY_TIPS'))
|
||||
}
|
||||
const startTime = Date.now()
|
||||
const output = await picgo.upload(capturedTask.input, capturedTask.options)
|
||||
if (Array.isArray(output) && output.some((item: ImgInfo) => item.imgUrl)) {
|
||||
if (context.active && isAvailableWebContents(capturedTask.webContents)) {
|
||||
try {
|
||||
await dataReportManager.reportUploadData(capturedTask.webContents, {
|
||||
fromClipboard: !capturedTask.input,
|
||||
duration: Date.now() - startTime,
|
||||
outputList: output
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
return output.filter(item => item.imgUrl)
|
||||
}
|
||||
return false
|
||||
} catch (e: unknown) {
|
||||
logger.error(e)
|
||||
setTimeout(() => {
|
||||
showNotification({
|
||||
title: T('UPLOAD_FAILED'),
|
||||
body: util.format(e instanceof Error ? e.stack : e),
|
||||
clickToCopy: true
|
||||
})
|
||||
}, 500)
|
||||
return false
|
||||
} finally {
|
||||
context.active = false
|
||||
this.cleanupRenameListeners(context)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Uploader()
|
||||
export type { UploadTask } from './types'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { UploadOptions } from 'picgo'
|
||||
|
||||
export interface UploadTask {
|
||||
input?: IUploadOption
|
||||
webContents?: WebContents
|
||||
options?: UploadOptions
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class GuiApi implements IGuiApi {
|
||||
async upload (input: IUploadOption) {
|
||||
this.windowId = await getWindowId()
|
||||
const webContents = this.getWebContentsByWindowId(this.windowId)
|
||||
const imgs = await uploader.setWebContents(webContents!).upload(input)
|
||||
const imgs = await uploader.upload({ input, webContents })
|
||||
if (imgs !== false) {
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
const pasteText: string[] = []
|
||||
@@ -91,8 +91,10 @@ class GuiApi implements IGuiApi {
|
||||
await AlbumDB.getInstance().insert(imgs[i])
|
||||
}
|
||||
handleCopyUrl(pasteText.join('\n'))
|
||||
webContents?.send('uploadFiles', imgs)
|
||||
webContents?.send(IRPCActionType.UPDATE_ALBUM)
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send(IRPCActionType.UPLOAD_COMPLETED, imgs)
|
||||
webContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
return imgs
|
||||
}
|
||||
return []
|
||||
|
||||
@@ -52,8 +52,9 @@ export default {
|
||||
// from macOS tray
|
||||
ipcMain.on('uploadClipboardFiles', async () => {
|
||||
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)!
|
||||
const webContents = trayWindow.webContents
|
||||
// macOS use builtin clipboard is OK
|
||||
const img = await uploader.setWebContents(trayWindow.webContents).uploadWithBuildInClipboard()
|
||||
const img = await uploader.uploadWithBuildInClipboard({ webContents })
|
||||
if (img !== false) {
|
||||
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
|
||||
handleCopyUrl(pasteTemplate(pasteStyle, img[0], picgo.getConfig<string>('settings.customLink')))
|
||||
@@ -64,17 +65,17 @@ export default {
|
||||
// icon: img[0].imgUrl
|
||||
})
|
||||
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_ALBUM)
|
||||
if (!webContents.isDestroyed()) webContents.send('clipboardFiles', [])
|
||||
const settingsContents = windowManager.get(IWindowList.SETTING_WINDOW)?.webContents
|
||||
if (settingsContents && !settingsContents.isDestroyed()) {
|
||||
settingsContents.send(IRPCActionType.UPDATE_ALBUM)
|
||||
}
|
||||
}
|
||||
trayWindow.webContents.send('uploadFiles')
|
||||
if (!webContents.isDestroyed()) webContents.send(IRPCActionType.UPLOAD_COMPLETED)
|
||||
})
|
||||
|
||||
ipcMain.on('uploadClipboardFilesFromUploadPage', () => {
|
||||
console.log('handle')
|
||||
uploadClipboardFiles()
|
||||
ipcMain.on('uploadClipboardFilesFromUploadPage', async (evt: IpcMainEvent) => {
|
||||
await uploadClipboardFiles(undefined, evt.sender)
|
||||
})
|
||||
|
||||
ipcMain.on('uploadChoosedFiles', async (evt: IpcMainEvent, files: IFileWithPath[]) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dialog } from 'electron'
|
||||
import windowManager from '~/main/apis/app/window/windowManager'
|
||||
import { IWindowList } from '#/types/enum'
|
||||
import { createSchemaOnlyUploaderContext } from '~/main/utils/schemaOnlyUploaderContext'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
|
||||
const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const
|
||||
|
||||
@@ -26,15 +27,15 @@ function handleRestoreState (fullName: string) {
|
||||
const currentUploader =
|
||||
picgo.getConfig<string>('picBed.uploader') ||
|
||||
picgo.getConfig<string>('picBed.current') ||
|
||||
'smms'
|
||||
PICGO_CLOUD_UPLOADER_TYPE
|
||||
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'
|
||||
configToRestore['picBed.current'] = PICGO_CLOUD_UPLOADER_TYPE
|
||||
configToRestore['picBed.uploader'] = PICGO_CLOUD_UPLOADER_TYPE
|
||||
}
|
||||
|
||||
if (transformerName && currentTransformer === transformerName) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
uploadSelectedFilesWithInfo
|
||||
} from 'apis/app/uploader/apis'
|
||||
import { getFormImageFolderPath } from 'apis/core/datastore/dbChecker'
|
||||
import type { IInternalServerManager } from 'picgo/dist/types/internal'
|
||||
import type { IInternalServerManager, IServerUploadAdapter } from 'picgo/dist/types/internal'
|
||||
|
||||
class Server {
|
||||
private config: IServerConfig
|
||||
@@ -43,14 +43,15 @@ class Server {
|
||||
private ensureUploadAdapterInstalled () {
|
||||
if (this.hasInstalledUploadAdapter) return
|
||||
const server = picgo.server as IInternalServerManager
|
||||
server.setUploadAdapter({
|
||||
uploadClipboard: async () => await uploadClipboardFilesWithInfo(),
|
||||
uploadPaths: async (paths: string[]) => {
|
||||
const uploadAdapter: IServerUploadAdapter = {
|
||||
uploadClipboard: async (options) => await uploadClipboardFilesWithInfo(options),
|
||||
uploadPaths: async (paths, options) => {
|
||||
const win = windowManager.getAvailableWindow()
|
||||
return await uploadSelectedFilesWithInfo(win.webContents, paths.map(item => ({ path: item })))
|
||||
return await uploadSelectedFilesWithInfo(win.webContents, paths.map(item => ({ path: item })), options)
|
||||
},
|
||||
getTempDir: getFormImageFolderPath
|
||||
})
|
||||
}
|
||||
server.setUploadAdapter(uploadAdapter)
|
||||
this.hasInstalledUploadAdapter = true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,78 @@
|
||||
import { ipcMain, IpcMainEvent, type WebContents } from "electron"
|
||||
import { deviceIdManager } from "./deviceId"
|
||||
import { REGISTER_DEVICE_ID, TALKING_DATA_EVENT } from "~/universal/events/constants"
|
||||
import type { IImgInfo } from "picgo"
|
||||
import { calcUploadBigFileSizeRange, calcUploadProcessDurationRange, calcVideoDurationRange } from "./common"
|
||||
import { app } from "electron/main"
|
||||
import picgo from "@core/picgo"
|
||||
import { getVideoDuration } from "@picgo/video-duration"
|
||||
import { MB, SECOND } from "./constants"
|
||||
import { app, ipcMain, type IpcMainEvent, type WebContents } from 'electron'
|
||||
import { deviceIdManager } from './deviceId'
|
||||
import { REGISTER_DEVICE_ID, TALKING_DATA_EVENT } from '~/universal/events/constants'
|
||||
import type { IImgInfo } from 'picgo'
|
||||
import { calcUploadBigFileSizeRange, calcUploadProcessDurationRange, calcVideoDurationRange } from './common'
|
||||
import picgo from '@core/picgo'
|
||||
import { getVideoDuration } from '@picgo/video-duration'
|
||||
import { MB, SECOND } from './constants'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
|
||||
export interface IReportUploadItem extends IImgInfo {
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface IReportUploadDataOptions {
|
||||
fromClipboard: boolean;
|
||||
duration: number;
|
||||
outputList: IImgInfo[];
|
||||
fromClipboard: boolean
|
||||
duration: number
|
||||
outputList: IReportUploadItem[]
|
||||
}
|
||||
|
||||
class DataReportManager {
|
||||
private deviceId: string | null = null
|
||||
private hasRegisterDeviceID: boolean = false
|
||||
private hasRegisterDeviceID = false
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
constructor () {
|
||||
this.init()
|
||||
this.handleRegisterDeviceID()
|
||||
}
|
||||
private handleRegisterDeviceID() {
|
||||
|
||||
private handleRegisterDeviceID () {
|
||||
ipcMain.once(REGISTER_DEVICE_ID, (_evt: IpcMainEvent) => {
|
||||
this.hasRegisterDeviceID = true
|
||||
console.log('Device ID registered')
|
||||
})
|
||||
}
|
||||
|
||||
private async init () {
|
||||
if (this.deviceId) return
|
||||
this.deviceId = await deviceIdManager.getId()
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.loadDeviceId()
|
||||
}
|
||||
await this.initPromise
|
||||
}
|
||||
public async reportUploadData(webContents: WebContents, options: IReportUploadDataOptions) {
|
||||
|
||||
private async loadDeviceId () {
|
||||
try {
|
||||
this.deviceId = await deviceIdManager.getId()
|
||||
} finally {
|
||||
this.initPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
public async reportUploadData (webContents: WebContents, options: IReportUploadDataOptions) {
|
||||
await this.init()
|
||||
if (webContents.isDestroyed()) return
|
||||
await this.registerDeviceID(webContents)
|
||||
if (webContents.isDestroyed()) return
|
||||
|
||||
const { fromClipboard, duration, outputList } = options
|
||||
const fileList = outputList.map(item => {
|
||||
return {
|
||||
fileName: item.fileName,
|
||||
filePath: item.filePath,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size || 0
|
||||
size: item.size || 0,
|
||||
type: item.type
|
||||
}
|
||||
})
|
||||
let legacyUploaderType: string | undefined
|
||||
const resolveUploaderType = (type?: string): string => {
|
||||
if (type) return type
|
||||
legacyUploaderType ??= picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || PICGO_CLOUD_UPLOADER_TYPE
|
||||
return legacyUploaderType
|
||||
}
|
||||
const batchOutput = outputList.find(item => item.type)
|
||||
const uploadEventData: ITalkingDataOptions = {
|
||||
EventId: 'upload',
|
||||
Label: '',
|
||||
@@ -50,11 +80,11 @@ class DataReportManager {
|
||||
by: fromClipboard ? 'clipboard' : 'files', // 上传剪贴板图片还是选择的文文件
|
||||
count: fileList.length, // 上传的数量
|
||||
duration: calcUploadProcessDurationRange(duration || 0), // 上传耗时
|
||||
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
|
||||
type: resolveUploaderType(batchOutput?.type)
|
||||
}
|
||||
}
|
||||
this.reportDataToWebContents(webContents, uploadEventData)
|
||||
fileList.forEach(async file => {
|
||||
await Promise.all(fileList.map(async file => {
|
||||
if (file?.mimeType?.startsWith('video/') && file.filePath) {
|
||||
const metadata = await getVideoDuration(file.filePath)
|
||||
const sizeMB = metadata.size / MB
|
||||
@@ -63,10 +93,10 @@ class DataReportManager {
|
||||
EventId: 'upload_video',
|
||||
Label: '',
|
||||
MapKv: {
|
||||
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
|
||||
type: resolveUploaderType(file.type),
|
||||
mimeType: file.mimeType,
|
||||
sizeRange: calcUploadBigFileSizeRange(sizeMB),
|
||||
durationRange: calcVideoDurationRange(durationSec),
|
||||
durationRange: calcVideoDurationRange(durationSec)
|
||||
}
|
||||
}
|
||||
this.reportDataToWebContents(webContents, videoEventData)
|
||||
@@ -75,7 +105,7 @@ class DataReportManager {
|
||||
EventId: 'upload_image',
|
||||
Label: '',
|
||||
MapKv: {
|
||||
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
|
||||
type: resolveUploaderType(file.type),
|
||||
mimeType: file.mimeType || 'image/png',
|
||||
sizeRange: calcUploadBigFileSizeRange(file.size / MB)
|
||||
}
|
||||
@@ -86,23 +116,25 @@ class DataReportManager {
|
||||
EventId: 'upload_file',
|
||||
Label: '',
|
||||
MapKv: {
|
||||
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
|
||||
type: resolveUploaderType(file.type),
|
||||
mimeType: file.mimeType || 'UNKNOWN',
|
||||
sizeRange: calcUploadBigFileSizeRange(file.size / MB)
|
||||
}
|
||||
}
|
||||
this.reportDataToWebContents(webContents, otherEventData)
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
public async registerDeviceID(webContents: WebContents) {
|
||||
public async registerDeviceID (webContents: WebContents) {
|
||||
if (this.hasRegisterDeviceID) return
|
||||
await this.init()
|
||||
if (webContents.isDestroyed()) return
|
||||
webContents.send(REGISTER_DEVICE_ID, this.deviceId)
|
||||
}
|
||||
|
||||
private reportDataToWebContents(webContents: WebContents, data: ITalkingDataOptions) {
|
||||
private reportDataToWebContents (webContents: WebContents, data: ITalkingDataOptions) {
|
||||
if (webContents.isDestroyed()) return
|
||||
webContents.send(TALKING_DATA_EVENT, {
|
||||
...data,
|
||||
MapKv: {
|
||||
@@ -114,15 +146,15 @@ class DataReportManager {
|
||||
})
|
||||
}
|
||||
|
||||
private getAreaFromTimezone() {
|
||||
private getAreaFromTimezone () {
|
||||
try {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (!timeZone) return 'UNKNOWN'
|
||||
|
||||
|
||||
if (timeZone === 'Asia/Shanghai') return 'CN'
|
||||
|
||||
|
||||
const region = timeZone.split('/')[0] // Asia, America, Europe
|
||||
return region
|
||||
return region
|
||||
} catch (e) {
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ export function useTrayUploadedItems () {
|
||||
}
|
||||
})
|
||||
|
||||
useIPCOn('uploadFiles', async (items: ImgInfo[] = []) => {
|
||||
useIPCOn(IRPCActionType.UPLOAD_COMPLETED, async (items: ImgInfo[] = []) => {
|
||||
if (isCloudSource) {
|
||||
// uploadFiles only means PicGo upload succeeded. In cloud mode, refreshing
|
||||
// UPLOAD_COMPLETED means the GUI upload flow ended. In cloud mode, refreshing
|
||||
// here races with UPDATE_CLOUD_ALBUM and causes duplicate album.list calls.
|
||||
// The cloud list is refreshed by UPDATE_CLOUD_ALBUM below.
|
||||
setUploadFlag(false)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ProviderUploaderSchema,
|
||||
ProviderUploaderSummary,
|
||||
} from "./types"
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from "#/utils/static"
|
||||
|
||||
interface ProviderUploaderStoreItem {
|
||||
summary: Omit<ProviderUploaderSummary, "isDefaultUploader">
|
||||
@@ -67,6 +68,21 @@ function createConfigItem(
|
||||
}
|
||||
|
||||
const store = new Map<string, ProviderUploaderStoreItem>([
|
||||
[
|
||||
PICGO_CLOUD_UPLOADER_TYPE,
|
||||
{
|
||||
summary: {
|
||||
id: PICGO_CLOUD_UPLOADER_TYPE,
|
||||
name: "PicGo Cloud",
|
||||
visible: true,
|
||||
},
|
||||
schema: [],
|
||||
configState: {
|
||||
defaultId: "",
|
||||
configList: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"github",
|
||||
{
|
||||
@@ -533,7 +549,7 @@ Array.from(store.values()).forEach((item) => {
|
||||
})
|
||||
|
||||
let idSeed = 100
|
||||
let defaultUploaderId = "smms"
|
||||
let defaultUploaderId = PICGO_CLOUD_UPLOADER_TYPE
|
||||
|
||||
function nextId(uploaderId: string) {
|
||||
idSeed += 1
|
||||
@@ -591,6 +607,12 @@ function resolveSelectedId(configState: ProviderUploaderConfigList) {
|
||||
}
|
||||
|
||||
function resolveFirstAvailableUploaderId() {
|
||||
const picGoCloud = store.get(PICGO_CLOUD_UPLOADER_TYPE)
|
||||
|
||||
if (picGoCloud?.summary.visible) {
|
||||
return PICGO_CLOUD_UPLOADER_TYPE
|
||||
}
|
||||
|
||||
const firstVisible = Array.from(store.values()).find((item) => item.summary.visible)
|
||||
|
||||
if (firstVisible) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderUploaderSummary
|
||||
} from '@/components/main/providers/types'
|
||||
import { createSelectors } from './create-selectors'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
|
||||
export const PicGoCloudLoginStatusValues = {
|
||||
Idle: 'IDLE',
|
||||
@@ -39,7 +40,7 @@ export interface AppStoreState {
|
||||
}
|
||||
|
||||
export const initialAppStoreState: AppStoreState = {
|
||||
defaultPicBed: 'smms',
|
||||
defaultPicBed: PICGO_CLOUD_UPLOADER_TYPE,
|
||||
appConfig: null,
|
||||
picBeds: [],
|
||||
providers: [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IConfig } from 'picgo'
|
||||
import { PICGO_CLOUD_UPLOADER_TYPE } from '#/utils/static'
|
||||
import type {
|
||||
AppConfig,
|
||||
ProviderUploaderSummary
|
||||
@@ -183,11 +184,7 @@ export function normalizeAlbumViewMode (value: string | undefined): AlbumViewMod
|
||||
}
|
||||
|
||||
export function resolveDefaultPicBed (config: IConfig | null) {
|
||||
if (!config) {
|
||||
return 'smms'
|
||||
}
|
||||
|
||||
return config.picBed.uploader || config.picBed.current || 'smms'
|
||||
return config?.picBed?.uploader || config?.picBed?.current || PICGO_CLOUD_UPLOADER_TYPE
|
||||
}
|
||||
|
||||
export function normalizePicBedList (
|
||||
|
||||
@@ -114,6 +114,7 @@ export enum IRPCActionType {
|
||||
|
||||
// gallery and toolbox rpc
|
||||
UPDATE_ALBUM = 'UPDATE_ALBUM',
|
||||
UPLOAD_COMPLETED = 'UPLOAD_COMPLETED',
|
||||
GET_ALBUM_MENU_LIST = 'GET_ALBUM_MENU_LIST',
|
||||
OPEN_CONFIG_DIALOG = 'OPEN_CONFIG_DIALOG',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user