├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .jsbeautifyrc ├── LICENSE ├── Makefile ├── Readme.md ├── create-desktop-file.sh ├── docker └── build.dockerfile ├── gulpfile.js ├── jest.config.js ├── launchConfig.schema.json ├── no-sandbox-shortcut.cmd ├── package.json ├── postcss.config.js ├── src ├── __mocks__ │ ├── fileMock.ts │ └── styleMock.ts ├── common │ ├── const │ │ ├── app-config.ts │ │ ├── byte-size.test.ts │ │ ├── byte-size.ts │ │ ├── duration.test.ts │ │ ├── duration.ts │ │ └── str.ts │ ├── ipc-actions │ │ ├── deep-link.ts │ │ ├── download.ts │ │ ├── types.ts │ │ └── upload.ts │ ├── models │ │ ├── job │ │ │ ├── crc32.test.ts │ │ │ ├── crc32.ts │ │ │ ├── download-job.test.ts │ │ │ ├── download-job.ts │ │ │ ├── transfer-job.ts │ │ │ ├── types.ts │ │ │ ├── upload-job.test.ts │ │ │ └── upload-job.ts │ │ └── storage-class.ts │ ├── qiniu │ │ ├── _mock-helpers_ │ │ │ ├── adapter.ts │ │ │ ├── auth.ts │ │ │ ├── config-file.ts │ │ │ ├── data.ts │ │ │ ├── downloader.ts │ │ │ ├── qiniu-path.ts │ │ │ └── uploader.ts │ │ ├── create-client.ts │ │ ├── index.ts │ │ └── types.ts │ └── utility-types.ts ├── main │ ├── deep-links │ │ ├── handler.test.ts │ │ ├── handler.ts │ │ ├── index.ts │ │ └── register.ts │ ├── download-worker.ts │ ├── index.ts │ ├── kv-store │ │ ├── data-store.test.ts │ │ ├── data-store.ts │ │ ├── disk-table.test.ts │ │ ├── disk-table.ts │ │ ├── index.ts │ │ ├── mem-table.ts │ │ ├── wal.test.ts │ │ └── wal.ts │ ├── lockfile │ │ └── index.ts │ ├── transfer-managers │ │ ├── boundary-const.ts │ │ ├── download-manager.test.ts │ │ ├── download-manager.ts │ │ ├── single-flight.test.ts │ │ ├── single-flight.ts │ │ ├── transfer-manager.ts │ │ ├── upload-manager.test.ts │ │ └── upload-manager.ts │ └── upload-worker.ts └── renderer │ ├── app-life │ ├── before-quit.ts │ ├── before-start.ts │ └── index.ts │ ├── app.tsx │ ├── components │ ├── base-grid │ │ ├── base-grid.scss │ │ └── index.tsx │ ├── batch-progress │ │ ├── batch-progress.tsx │ │ ├── error-file-list.tsx │ │ ├── index.tsx │ │ ├── types.ts │ │ └── use-batch-progress.ts │ ├── deep-link-actions │ │ └── index.tsx │ ├── drop-zone │ │ ├── drop-zone.scss │ │ └── index.tsx │ ├── empty-holder │ │ └── index.tsx │ ├── forms │ │ ├── change-storage-class-form.tsx │ │ ├── generate-link-form │ │ │ ├── domain-name-field.tsx │ │ │ ├── domain-name-select.tsx │ │ │ ├── expire-after-field.tsx │ │ │ ├── file-link-field.tsx │ │ │ ├── file-name-field.tsx │ │ │ ├── generate-link-form.tsx │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── restore-form.tsx │ │ └── types.ts │ ├── kodo-address-bar │ │ └── index.tsx │ ├── lite-confirm │ │ ├── index.tsx │ │ └── use-promise-confirm.ts │ ├── loading-holder │ │ └── index.tsx │ ├── modals │ │ ├── bucket │ │ │ ├── create-bucket │ │ │ │ └── index.tsx │ │ │ ├── delete-bucket │ │ │ │ └── index.tsx │ │ │ └── update-bucket-remark │ │ │ │ └── index.tsx │ │ ├── common │ │ │ └── confirm-modal │ │ │ │ └── index.tsx │ │ ├── external-path │ │ │ ├── add-external-path │ │ │ │ └── index.tsx │ │ │ └── delete-external-path │ │ │ │ └── index.tsx │ │ ├── file │ │ │ ├── change-file-storage-class │ │ │ │ └── index.tsx │ │ │ ├── change-files-storage-class │ │ │ │ └── index.tsx │ │ │ ├── common │ │ │ │ └── file-list.tsx │ │ │ ├── copy-files │ │ │ │ └── index.tsx │ │ │ ├── create-dir-share-link │ │ │ │ └── index.tsx │ │ │ ├── create-directory-file │ │ │ │ └── index.tsx │ │ │ ├── delete-files │ │ │ │ └── index.tsx │ │ │ ├── generate-file-link │ │ │ │ └── index.tsx │ │ │ ├── generate-file-links │ │ │ │ └── index.tsx │ │ │ ├── move-files │ │ │ │ └── index.tsx │ │ │ ├── rename-file │ │ │ │ └── index.tsx │ │ │ ├── restore-file │ │ │ │ └── index.tsx │ │ │ ├── restore-files │ │ │ │ └── index.tsx │ │ │ ├── types.ts │ │ │ ├── upload-files-confirm │ │ │ │ └── index.tsx │ │ │ └── utils.ts │ │ ├── general │ │ │ ├── about │ │ │ │ ├── about.scss │ │ │ │ ├── download-update.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── preview-update.tsx │ │ │ │ └── use-download-update.tsx │ │ │ ├── ak-history │ │ │ │ ├── ak-table-row.tsx │ │ │ │ └── index.tsx │ │ │ ├── bookmark-manager │ │ │ │ ├── bookmark-table-row.tsx │ │ │ │ └── index.tsx │ │ │ ├── private-cloud-settings │ │ │ │ ├── index.tsx │ │ │ │ └── region-inputs.tsx │ │ │ ├── release-note-modal │ │ │ │ └── index.tsx │ │ │ └── settings-modal │ │ │ │ ├── fields-download.tsx │ │ │ │ ├── fields-external-path.tsx │ │ │ │ ├── fields-others.tsx │ │ │ │ ├── fields-upload.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── settings.scss │ │ │ │ └── types.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── modal-counter-store.ts │ │ │ ├── use-display-modal.ts │ │ │ ├── use-is-show-any-modal.ts │ │ │ └── use-submit-modal.ts │ │ └── preview-file │ │ │ ├── file-content │ │ │ ├── audio-content.tsx │ │ │ ├── code-content.tsx │ │ │ ├── index.tsx │ │ │ ├── others-content.tsx │ │ │ ├── picture-content.tsx │ │ │ └── video-content.tsx │ │ │ ├── file-operation │ │ │ ├── change-storage-class.tsx │ │ │ ├── generate-link.tsx │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── precheck │ │ │ ├── file-archived.tsx │ │ │ ├── file-empty-name.tsx │ │ │ └── file-too-large.tsx │ ├── tooltip-button │ │ └── index.tsx │ ├── tooltip-text │ │ └── index.tsx │ ├── top │ │ ├── index.tsx │ │ ├── menu-contents.tsx │ │ ├── menu-item.ts │ │ └── top.scss │ └── transfer-panel │ │ ├── const.ts │ │ ├── download-job-operation.tsx │ │ ├── download-panel.tsx │ │ ├── index.tsx │ │ ├── job-item.tsx │ │ ├── transfer-panel.scss │ │ ├── upload-job-operation.tsx │ │ ├── upload-panel.tsx │ │ ├── use-ipc-download.ts │ │ └── use-ipc-upload.ts │ ├── const │ ├── acl.ts │ ├── kodo-nav.ts │ ├── patterns.test.ts │ └── patterns.ts │ ├── customize.ts │ ├── index.ejs │ ├── index.tsx │ ├── modules │ ├── audit-log │ │ └── index.ts │ ├── auth │ │ ├── cipher.test.ts │ │ ├── cipher.ts │ │ ├── functions.ts │ │ ├── index.ts │ │ ├── persistence.ts │ │ ├── react-context.tsx │ │ └── types.ts │ ├── codemirror │ │ ├── code-mirror-container.scss │ │ ├── compatible.ts │ │ ├── diff-view.tsx │ │ ├── editor-view.tsx │ │ ├── hooks │ │ │ ├── use-editor-view.ts │ │ │ └── use-merge-view.ts │ │ └── index.tsx │ ├── default-dict │ │ └── index.ts │ ├── electron-ipc-manages │ │ ├── ipc-download-manager.ts │ │ ├── ipc-upload-manager.ts │ │ └── use-ipc-deep-links.ts │ ├── external-store │ │ └── index.ts │ ├── file-operation │ │ └── index.tsx │ ├── hooks │ │ ├── use-is-overflow.ts │ │ ├── use-mount.ts │ │ ├── use-portal.tsx │ │ ├── use-raf-state.ts │ │ ├── use-scroll.ts │ │ └── use-unmount.ts │ ├── i18n │ │ ├── core.ts │ │ ├── extra │ │ │ ├── index.ts │ │ │ └── job-status.ts │ │ ├── index.ts │ │ ├── lang │ │ │ ├── dict.ts │ │ │ ├── en-us.ts │ │ │ ├── ja-jp.ts │ │ │ └── zh-cn.ts │ │ ├── react-component.tsx │ │ └── react-context.tsx │ ├── kodo-address │ │ ├── index.ts │ │ ├── navigator.ts │ │ ├── react-context.tsx │ │ └── types.ts │ ├── launch-config │ │ ├── chore-configs.ts │ │ ├── default-private-endpoint.ts │ │ ├── disable-functions.ts │ │ ├── index.ts │ │ ├── preference-validator.ts │ │ └── types.ts │ ├── local-logger │ │ ├── index.ts │ │ ├── levels.ts │ │ ├── loggers.ts │ │ └── parse-error-stack.ts │ ├── markdown │ │ ├── index.tsx │ │ └── use-markdown.ts │ ├── persistence │ │ ├── browser-storage.ts │ │ ├── index.ts │ │ ├── local-file.ts │ │ ├── persistence.ts │ │ └── serializer.ts │ ├── qiniu-client-hooks │ │ ├── index.ts │ │ ├── use-frozen-info.ts │ │ ├── use-head-file.ts │ │ ├── use-load-buckets.ts │ │ ├── use-load-domains.ts │ │ ├── use-load-files.ts │ │ └── use-load-regions.ts │ ├── qiniu-client │ │ ├── bucket-item.ts │ │ ├── buckets.test.ts │ │ ├── buckets.ts │ │ ├── common.test.ts │ │ ├── common.ts │ │ ├── file-item.test.ts │ │ ├── file-item.ts │ │ ├── files.test.ts │ │ ├── files.ts │ │ ├── index.ts │ │ ├── region.test.ts │ │ ├── regions.ts │ │ ├── share.ts │ │ ├── types.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── update-app │ │ ├── download-whole.ts │ │ ├── fetch-version.ts │ │ ├── index.ts │ │ ├── migrate-steps │ │ │ ├── 2.2.0 │ │ │ │ ├── downgrade.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── upgrade.ts │ │ │ └── index.ts │ │ ├── migrator.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ └── user-config-store │ │ ├── app-preferences.ts │ │ ├── bookmark-path.ts │ │ ├── endpoint-config.ts │ │ ├── error-handler.ts │ │ ├── external-path.ts │ │ ├── index.ts │ │ └── user-config-store.ts │ ├── pages │ ├── browse-share │ │ ├── contents.tsx │ │ └── index.tsx │ ├── browse │ │ ├── buckets │ │ │ ├── bucket-grid-cell.tsx │ │ │ ├── bucket-grid.scss │ │ │ ├── bucket-grid.tsx │ │ │ ├── bucket-table-row.tsx │ │ │ ├── bucket-table.tsx │ │ │ ├── bucket-tool-bar.tsx │ │ │ └── index.tsx │ │ ├── contents.tsx │ │ ├── external-paths │ │ │ ├── external-path-table-row.tsx │ │ │ ├── external-path-table.tsx │ │ │ ├── external-path-tool-bar.tsx │ │ │ └── index.tsx │ │ ├── files │ │ │ ├── auto-fill-first-view │ │ │ │ └── index.tsx │ │ │ ├── const.ts │ │ │ ├── file-content.tsx │ │ │ ├── file-grid │ │ │ │ ├── file-cell.tsx │ │ │ │ ├── file-grid.scss │ │ │ │ └── index.tsx │ │ │ ├── file-table │ │ │ │ ├── columns │ │ │ │ │ ├── file-checkbox.tsx │ │ │ │ │ ├── file-name.tsx │ │ │ │ │ ├── file-operations.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── file-table.scss │ │ │ │ └── index.tsx │ │ │ ├── file-tool-bar.tsx │ │ │ ├── files.scss │ │ │ ├── index.tsx │ │ │ ├── overlay-holder │ │ │ │ ├── index.tsx │ │ │ │ └── overlay-holder.scss │ │ │ ├── select-prefix.tsx │ │ │ └── types.ts │ │ ├── index.tsx │ │ └── transfer │ │ │ └── index.tsx │ ├── common │ │ ├── about-menu-item.tsx │ │ └── top-menu.tsx │ ├── exceptions │ │ └── not-found.tsx │ ├── route-path.ts │ ├── sign-in │ │ ├── index.tsx │ │ ├── sign-in-form.scss │ │ ├── sign-in-form.tsx │ │ ├── sign-in-share-form.tsx │ │ └── sign-in.scss │ ├── sign-out │ │ └── index.tsx │ └── switch-user │ │ └── index.tsx │ ├── setup-app.tsx │ ├── static │ ├── brand │ │ ├── qiniu.icns │ │ ├── qiniu.ico │ │ └── qiniu.png │ ├── diff_match_patch.js │ ├── flv-player.html │ └── flv.js │ └── styles │ ├── animations │ ├── bg-colorful.scss │ ├── index.scss │ ├── invalid-text.scss │ └── loading-spin.scss │ ├── base-table │ └── index.scss │ ├── bootstrap │ ├── button.scss │ ├── card.scss │ ├── index.scss │ ├── modal.scss │ ├── nav.scss │ ├── root.scss │ └── utils.scss │ ├── hot-toast │ └── index.scss │ ├── iconfont │ ├── iconfont.css │ ├── iconfont.woff │ └── iconfont.woff2 │ ├── index.scss │ ├── reset.scss │ └── utils │ ├── box.scss │ ├── css-grid.scss │ ├── index.scss │ ├── scroll.scss │ └── text.scss ├── tsconfig.json ├── webpack ├── paths.js ├── webpack-main.config.js ├── webpack-renderer.config.js └── webpack.config.js ├── yarn.lock └── zip.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 120 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "rules": { 10 | "semi": 2 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test Build 3 | jobs: 4 | build-on-linux: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | with: 9 | ref: ${{ github.ref }} 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '16.x' 13 | - name: install wine 14 | # https://github.com/actions/virtual-environments/issues/4589 15 | run: | 16 | sudo dpkg --add-architecture i386 17 | sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 86B72ED9 18 | sudo add-apt-repository 'deb [arch=amd64] https://mirror.mxe.cc/repos/apt focal main' 19 | sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list 20 | sudo apt -qq update 21 | sudo apt install -yqq --allow-downgrades libgd3/focal libpcre2-8-0/focal libpcre2-16-0/focal libpcre2-32-0/focal libpcre2-posix2/focal 22 | sudo apt purge -yqq libmono* mono* php* libgdiplus libpcre2-posix3 23 | sudo apt install -y wine32 wine64 24 | - name: test 25 | run: make i test 26 | - name: build 27 | run: make linux32 linux64 win32 win64 28 | build-on-mac: 29 | runs-on: macos-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | with: 33 | ref: ${{ github.ref }} 34 | - uses: actions/setup-node@v1 35 | with: 36 | node-version: '16.x' 37 | - name: test 38 | run: make i test 39 | - name: build 40 | env: 41 | NODE_OPTIONS: "--max_old_space_size=4096" 42 | run: make mac 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | cache 4 | dist 5 | dist.asar 6 | npm-debug.log* 7 | bower_components 8 | build 9 | .DS_Store 10 | releases 11 | _up.sh 12 | package-lock.json 13 | *.download 14 | *.asar 15 | .nvmrc 16 | -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent_size": 2, 3 | "indent_char": " ", 4 | "other": " ", 5 | "indent_level": 0, 6 | "esversion": 6, 7 | "indent_with_tabs": false, 8 | "preserve_newlines": true, 9 | "max_preserve_newlines": 2, 10 | "jslint_happy": true, 11 | "indent_handlebars": true 12 | } 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=kodo-browser 2 | 3 | PKGER=node node_modules/electron-packager/cli.js 4 | ZIP=node ../zip.js 5 | 6 | i: 7 | yarn install 8 | test: 9 | yarn test 10 | dev: 11 | NODE_ENV=development electron . 12 | run: 13 | yarn dev 14 | clean: 15 | rm -rf dist node_modules build releases node/s3store/node_modules 16 | 17 | prod: 18 | yarn prod 19 | watch: 20 | yarn watch 21 | build: 22 | yarn build 23 | 24 | win64: build 25 | yarn build:win64 26 | yarn pkg:win64 27 | win32: build 28 | yarn build:win32 29 | yarn pkg:win32 30 | linux64: build 31 | yarn build:linux64 32 | yarn pkg:linux64 33 | linux32: build 34 | yarn build:linux32 35 | yarn pkg:linux32 36 | mac: build 37 | yarn build:mac 38 | yarn pkg:mac 39 | dmg: mac 40 | yarn build:dmg 41 | 42 | all:win32 win64 linux32 linux64 mac 43 | @echo 'Done' 44 | 45 | .PHONY:build i dev run clean prod watch win64 win32 linux64 linux32 mac dmg all 46 | -------------------------------------------------------------------------------- /create-desktop-file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | WORKING_DIR=$(pwd) 4 | THIS_PATH=$(readlink -f "$0") 5 | THIS_BASE_PATH=$(dirname "${THIS_PATH}") 6 | cd "$THIS_BASE_PATH" 7 | FULL_PATH=$(pwd) 8 | cd "${WORKING_DIR}" 9 | DESKTOP_FILE_NAME="kodo-browser.desktop" 10 | cat < "${DESKTOP_FILE_NAME}" 11 | [Desktop Entry] 12 | Name=Kodo Browser 13 | Comment=Kodo Browser for Linux 14 | Exec="${FULL_PATH}/Kodo Browser" %U 15 | Terminal=false 16 | Type=Application 17 | MimeType=x-scheme-handler/kodobrowser 18 | Icon=${FULL_PATH}/resources/app/renderer/static/brand/qiniu.png 19 | Categories=Utility;Development; 20 | EOS 21 | chmod +x "${DESKTOP_FILE_NAME}" 22 | -------------------------------------------------------------------------------- /docker/build.dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:buster 2 | MAINTAINER Rong Zhou "zhourong@qiniu.com" 3 | 4 | 5 | RUN echo 'deb http://mirrors.aliyun.com/debian/ buster main non-free contrib' > /etc/apt/sources.list && \ 6 | echo 'deb http://mirrors.aliyun.com/debian/ buster-proposed-updates main non-free contrib' >> /etc/apt/sources.list && \ 7 | apt-get update && \ 8 | DEBIAN_FRONTEND=noninteractive apt-get install -qy --no-install-recommends ca-certificates && \ 9 | echo 'deb https://mirrors.aliyun.com/debian/ buster main non-free contrib' > /etc/apt/sources.list && \ 10 | echo 'deb https://mirrors.aliyun.com/debian/ buster-proposed-updates main non-free contrib' >> /etc/apt/sources.list && \ 11 | apt-get update && \ 12 | DEBIAN_FRONTEND=noninteractive apt-get install -qy --no-install-recommends make wget xz-utils wine wine64 unzip python2.7 build-essential curl git tzdata && \ 13 | ln -sf /usr/bin/python2.7 /usr/bin/python && \ 14 | rm -rf /var/lib/apt/lists/* 15 | 16 | RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 17 | RUN curl -fsSL "http://resources.koderover.com/docker-cli-v19.03.2.tar.gz" -o docker.tgz && \ 18 | tar -xvzf docker.tgz && \ 19 | mv docker/* /usr/local/bin 20 | RUN curl -L "http://resource.koderover.com/reaper" -o reaper && \ 21 | chmod +x reaper && \ 22 | mv reaper /usr/local/bin 23 | RUN wget -q --tries=0 -c -O /tmp/node-v12.22.7-linux-x64.tar.xz http://mirrors.qiniu-solutions.com/node-v12.22.7-linux-x64.tar.xz && \ 24 | tar xf /tmp/node-v12.22.7-linux-x64.tar.xz --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md -C /usr/local && \ 25 | rm /tmp/node-v12.22.7-linux-x64.tar.xz 26 | RUN npm config set registry https://registry.npmmirror.com && \ 27 | npm install yarn -g && \ 28 | yarn config set registry https://registry.npmmirror.com && \ 29 | yarn config set electron_mirror https://repo.huaweicloud.com/electron/ 30 | RUN mkdir -p /root/.cache && \ 31 | wget -q --tries=0 -c -O /tmp/electron-cache-v4.2.7.tar.xz http://mirrors.qiniu-solutions.com/electron-cache-v4.2.7.tar.xz && \ 32 | tar xf /tmp/electron-cache-v4.2.7.tar.xz -C /root/.cache && \ 33 | rm /tmp/electron-cache-v4.2.7.tar.xz 34 | WORKDIR /kodo-browser 35 | ENTRYPOINT ["reaper"] 36 | -------------------------------------------------------------------------------- /no-sandbox-shortcut.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | 4 | set "script_dir=%~dp0" 5 | 6 | echo Set oShell = CreateObject("WScript.Shell") > %temp%\shortcut.vbs 7 | echo sLinkFile = "%userprofile%\Desktop\Kodo Browser(no sandbox).lnk" >> %temp%\shortcut.vbs 8 | echo Set oLink = oShell.CreateShortcut(sLinkFile) >> %temp%\shortcut.vbs 9 | echo oLink.TargetPath = "%script_dir%\Kodo Browser.exe" >> %temp%\shortcut.vbs 10 | echo oLink.Arguments = "--no-sandbox" >> %temp%\shortcut.vbs 11 | echo oLink.Save >> %temp%\shortcut.vbs 12 | cscript //nologo %temp%\shortcut.vbs "%script_dir%" 13 | del %temp%\shortcut.vbs -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-preset-env")({ 4 | features: { 5 | 'nesting-rules': true, 6 | }, 7 | autoprefixer: false, 8 | }), 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /src/__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /src/__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/common/const/app-config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import os from 'os'; 3 | 4 | import pkgJson from '../../../package.json'; 5 | 6 | export const app = { 7 | id: 'kodo-browser', 8 | logo: 'static/brand/qiniu.png', 9 | version: pkgJson.version, 10 | }; 11 | 12 | export const config_path = path.join(os.homedir(), '.kodo-browser-v2'); 13 | -------------------------------------------------------------------------------- /src/common/const/byte-size.test.ts: -------------------------------------------------------------------------------- 1 | import {byteSizeFormat} from "./byte-size"; 2 | 3 | describe("test byte-size", () => { 4 | describe("test byteSizeFormate", () => { 5 | { 6 | it("zero", () => { 7 | expect(byteSizeFormat(0, false)) 8 | .toBe("0"); 9 | expect(byteSizeFormat(NaN, false)) 10 | .toBe("0"); 11 | expect(byteSizeFormat(-1, false)) 12 | .toBe("0"); 13 | }); 14 | it("B", () => { 15 | expect(byteSizeFormat(1, false)) 16 | .toBe("1B"); 17 | expect(byteSizeFormat(1023, false)) 18 | .toBe("1023B"); 19 | expect(byteSizeFormat(1, true)) 20 | .toBe("1B"); 21 | }); 22 | it("KB", () => { 23 | expect(byteSizeFormat(1024, false)) 24 | .toBe("1K"); 25 | expect(byteSizeFormat(Math.pow(1024, 2) - 1, false)) 26 | .toBe("1023K1023B"); 27 | expect(byteSizeFormat(Math.pow(1024, 2) - 512, true)) 28 | .toBe("1023.5KB"); 29 | expect(byteSizeFormat(Math.pow(1024, 2) - 1024, true)) 30 | .toBe("1023KB"); 31 | expect(byteSizeFormat(Math.pow(1024, 2) - 1, true)) 32 | .toBe("1024KB"); 33 | }); 34 | it("MB", () => { 35 | expect(byteSizeFormat(Math.pow(1024, 2), false)) 36 | .toBe("1M"); 37 | expect(byteSizeFormat(Math.pow(1024, 3) - 1, false)) 38 | .toBe("1023M1023K1023B"); 39 | expect(byteSizeFormat(Math.pow(1024, 3) - Math.pow(1024, 2), true)) 40 | .toBe("1023MB"); 41 | expect(byteSizeFormat(Math.pow(1024, 3) - 1, true)) 42 | .toBe("1024MB"); 43 | }); 44 | it("GB", () => { 45 | expect(byteSizeFormat(Math.pow(1024, 3), false)) 46 | .toBe("1G"); 47 | expect(byteSizeFormat(Math.pow(1024, 4) - 1, false)) 48 | .toBe("1023G1023M1023K1023B"); 49 | expect(byteSizeFormat(Math.pow(1024, 4) - Math.pow(1024, 3), true)) 50 | .toBe("1023GB"); 51 | expect(byteSizeFormat(Math.pow(1024, 4) - 1, true)) 52 | .toBe("1024GB"); 53 | }); 54 | } 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/common/const/byte-size.ts: -------------------------------------------------------------------------------- 1 | enum ByteSize { 2 | KB = 1024, 3 | MB = 1024 * KB, 4 | GB = 1024 * MB, 5 | TB = 1024 * GB, 6 | } 7 | 8 | const descByteSize = Object.values(ByteSize).sort().reverse(); 9 | 10 | export function byteSizeFormat(n: number, isApproximate = true): string { 11 | if (n == 0 || !n || n < 0) { 12 | return "0"; 13 | } 14 | 15 | const t = []; 16 | let left = n; 17 | 18 | for (const v of descByteSize) { 19 | if (typeof v === "number") { 20 | const k = ByteSize[v]; 21 | const s = Math.floor(left / v); 22 | if (s > 0) { 23 | if (isApproximate) { 24 | return Math.round(100 * left / v) / 100 + k; 25 | } else { 26 | t.push(s + k[0]); 27 | left = left % v; 28 | } 29 | } 30 | } 31 | } 32 | 33 | if (left > 0) { 34 | left = Math.round(left) 35 | t.push(left + "B"); 36 | if (isApproximate) return left + "B"; 37 | } 38 | return t.length > 0 ? t.join("") : "0"; 39 | } 40 | 41 | export default ByteSize; 42 | -------------------------------------------------------------------------------- /src/common/const/duration.ts: -------------------------------------------------------------------------------- 1 | import {isNumber} from "lodash"; 2 | 3 | enum Duration { 4 | Millisecond = 1, 5 | Second = 1000 * Millisecond, 6 | Minute = 60 * Second, 7 | Hour = 60 * Minute, 8 | Day = 24 * Hour, 9 | } 10 | 11 | export default Duration; 12 | 13 | const formatAbbr = { 14 | [Duration.Millisecond]: "ms", 15 | [Duration.Second]: "s", 16 | [Duration.Minute]: "m", 17 | [Duration.Hour]: "h", 18 | [Duration.Day]: "D", 19 | } 20 | 21 | const descDuration = (Object.values(Duration) as any[]) 22 | .filter(v => isNumber(v)) 23 | .sort((a, b) => b - a); 24 | 25 | export function durationFormat(ms: number): string { 26 | if (Number.isNaN(ms)) { 27 | return "" 28 | } 29 | 30 | if (ms <= 0) { 31 | return "0"; 32 | } 33 | 34 | if (ms === Infinity) { 35 | return "∞" 36 | } 37 | 38 | if (ms < Duration.Second) { 39 | return Math.ceil(ms) + "ms"; 40 | } 41 | 42 | const t = []; 43 | let left = ms; 44 | 45 | for (const v of descDuration) { 46 | if (v > Duration.Millisecond) { 47 | const k = formatAbbr[v]; 48 | const s = Math.floor(left / v); 49 | if (s > 0) { 50 | t.push(s + k); 51 | left = left % v; 52 | } 53 | } 54 | } 55 | 56 | return t.join(" "); 57 | } 58 | 59 | // convert millisecond to other duration unit 60 | export function convertDuration(val: number, unit: Duration) { 61 | return val / unit; 62 | } 63 | -------------------------------------------------------------------------------- /src/common/const/str.ts: -------------------------------------------------------------------------------- 1 | export const ALPHABET_UPPERCASE = "abcdefghijklmnopqrstuvwxyz"; 2 | export const ALPHABET_LOWERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 3 | export const ALPHABET = ALPHABET_UPPERCASE + ALPHABET_LOWERCASE; 4 | export const DIGITS = "0123456789"; 5 | export const ALPHANUMERIC = ALPHABET + DIGITS; 6 | -------------------------------------------------------------------------------- /src/common/ipc-actions/types.ts: -------------------------------------------------------------------------------- 1 | export interface Sender { 2 | send(channel: string, message: T): void, 3 | } 4 | -------------------------------------------------------------------------------- /src/common/models/job/crc32.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs"; 2 | 3 | import crc32Async from "./crc32"; 4 | import ByteSize from "@common/const/byte-size"; 5 | 6 | describe("test crc32", () => { 7 | beforeAll(() => { 8 | mockFs({ 9 | "/path/to": { 10 | "file": "hello kodo browser!\n", 11 | "320KB-a.data": Buffer.alloc(5 * 64 * ByteSize.KB).fill(0x61), // 0x61 === 'a' 12 | }, 13 | }); 14 | }); 15 | afterAll(() => { 16 | mockFs.restore(); 17 | }); 18 | 19 | it("test crc32Async", async () => { 20 | const actual = await crc32Async("/path/to/file"); 21 | 22 | expect(actual).toBe("F9629E23"); 23 | }); 24 | 25 | it("test crc32Async read at least 3 times", async () => { 26 | // Because crc32-stream is a transform stream, it doesn't consume readable stream. 27 | // The default highWaterMark of file readable stream is 64KB, so we mock a 5 * 64KB file. 28 | const actual = await crc32Async("/path/to/320KB-a.data"); 29 | 30 | expect(actual).toBe("61399770"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/common/models/job/crc32.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import stream from "stream"; 3 | 4 | // @ts-ignore 5 | import {CRC32Stream} from "crc32-stream"; 6 | 7 | export default function (filePath: string): Promise { 8 | const fileStream = fs.createReadStream(filePath); 9 | const checksum = new CRC32Stream(); 10 | const emptyConsumer = new stream.Writable({ 11 | write(_chunk: Buffer, _encoding: BufferEncoding, callback: (error?: (Error | null)) => void) { 12 | callback(); 13 | } 14 | }); 15 | 16 | return new Promise(resolve => { 17 | fileStream 18 | .pipe(checksum) 19 | .pipe(emptyConsumer) 20 | .on("finish", () => { 21 | resolve(checksum.hex()); 22 | }); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/common/models/job/types.ts: -------------------------------------------------------------------------------- 1 | // job constructor options 2 | export interface UploadedPart { 3 | partNumber: number, 4 | etag: string, 5 | } 6 | 7 | export enum Status { 8 | Waiting = "waiting", 9 | Running = "running", 10 | Stopped = "stopped", 11 | Finished = "finished", 12 | Failed = "failed", 13 | Duplicated = "duplicated", 14 | Verifying = "verifying" 15 | } 16 | 17 | export interface LocalPath { 18 | name: string, 19 | path: string, 20 | size?: number, // bytes 21 | mtime?: number, // ms timestamp 22 | } 23 | 24 | export interface RemotePath { 25 | bucket: string, 26 | key: string, 27 | size?: number, // bytes 28 | mtime?: number, // ms timestamp 29 | } 30 | 31 | export interface ProgressCallbackParams { 32 | transferred: number, 33 | total: number, 34 | speed: number, 35 | eta: number, 36 | } 37 | 38 | export function isLocalPath(p: LocalPath | RemotePath): p is LocalPath { 39 | return p.hasOwnProperty("name"); 40 | } 41 | -------------------------------------------------------------------------------- /src/common/models/storage-class.ts: -------------------------------------------------------------------------------- 1 | export default interface StorageClass { 2 | fileType: number, 3 | kodoName: string, 4 | s3Name: string, 5 | billingI18n: Record, 6 | nameI18n: Record, 7 | } 8 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/auth.ts: -------------------------------------------------------------------------------- 1 | export const QINIU_ACCESS_KEY = "NgKd0BmebvsFERFEBfKVVZGeGn7VsZQe_H_AunOC"; 2 | export const QINIU_SECRET_KEY = "lp4Zv3Gi_7CHtxNTcJx2Pum5hUJB3gHROcg4vp0i"; 3 | export const QINIU_UC = "http://fake.qiniu.com"; 4 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/config-file.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs"; 2 | 3 | import { config_path } from "@common/const/app-config"; 4 | 5 | const CONFIG_MOCK_CONTENT = `{ 6 | "uc_url": "https://mocked-uc.qiniu.io", 7 | "regions": [ 8 | { 9 | "id": "mock-1", 10 | "label": "Mocked Region", 11 | "endpoint": "https://mocked-s3.pocdemo.qiniu.io" 12 | } 13 | ] 14 | }`; 15 | 16 | export function mockCustomizeConfigFile() { 17 | mockFs({ 18 | [config_path]: { 19 | "config.json": CONFIG_MOCK_CONTENT, 20 | }, 21 | }); 22 | } 23 | 24 | export function resetConfigFile() { 25 | mockFs.restore(); 26 | } 27 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/data.ts: -------------------------------------------------------------------------------- 1 | import { Region } from "kodo-s3-adapter-sdk"; 2 | 3 | /* 4 | * regions.ts 5 | * */ 6 | 7 | // getRegions 8 | 9 | const mockDataOfRegionBase = { 10 | upUrls: [], 11 | upAccUrls: [], 12 | ucUrls: [], 13 | rsUrls: [], 14 | rsfUrls: [], 15 | apiUrls: [], 16 | s3Urls: [], 17 | ttl: 86400, 18 | createTime: Date.now(), 19 | validated: true, 20 | }; 21 | 22 | export const mockDataOfGetAllRegions: Region[] = [ 23 | { 24 | ...mockDataOfRegionBase, 25 | id: "region-id-1", 26 | s3Id: 'region-s3-id-1', 27 | // exist label and matched translatedLabels 28 | label: 'region-label-1', 29 | translatedLabels: { 30 | "zh_CN": "中国中部", 31 | }, 32 | storageClasses: [], 33 | }, 34 | { 35 | ...mockDataOfRegionBase, 36 | id: "region-id-2", 37 | s3Id: 'region-s3-id-2', 38 | // exist label but translatedLabels empty 39 | label: 'region-label-2', 40 | translatedLabels: {}, 41 | storageClasses: [], 42 | }, 43 | { 44 | ...mockDataOfRegionBase, 45 | id: "region-id-3", 46 | s3Id: 'region-s3-id-3', 47 | // exist label but matched translatedLabels 48 | label: 'region-label-3', 49 | translatedLabels: { 50 | "en_US": "China Middle", 51 | }, 52 | storageClasses: [], 53 | }, 54 | { 55 | ...mockDataOfRegionBase, 56 | id: "region-id-4", 57 | s3Id: 'region-s3-id-4', 58 | translatedLabels: {}, 59 | storageClasses: [], 60 | // neither label and translatedLabels 61 | }, 62 | { 63 | ...mockDataOfRegionBase, 64 | id: "region-id-5", 65 | s3Id: 'region-s3-id-5', 66 | // neither label and matched translatedLabels 67 | translatedLabels: { 68 | "en_US": "China Middle", 69 | }, 70 | storageClasses: [], 71 | }, 72 | { 73 | ...mockDataOfRegionBase, 74 | id: "region-id-6", 75 | s3Id: 'region-s3-id-6', 76 | // exist matched translatedLabels but label 77 | translatedLabels: { 78 | "zh_CN": "中国中部", 79 | }, 80 | storageClasses: [], 81 | }, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/downloader.ts: -------------------------------------------------------------------------------- 1 | import fsPromises from "fs/promises"; 2 | 3 | export const MockDownloadContent = "mock download content"; 4 | 5 | export function mockDownloader() { 6 | const mockedDownloader = jest.fn(); 7 | mockedDownloader.constructor = mockedDownloader; 8 | mockedDownloader.prototype.getObjectToFile = async function (_region: string, _obj: Object, tempFilepath: string) { 9 | await fsPromises.writeFile( 10 | tempFilepath, 11 | MockDownloadContent, 12 | ); 13 | await new Promise(resolve => { 14 | setTimeout(resolve, 300); 15 | }); 16 | }; 17 | mockedDownloader.prototype.getObjectToFile = 18 | jest.fn(mockedDownloader.prototype.getObjectToFile); 19 | mockedDownloader.prototype.abort = jest.fn(); 20 | 21 | return { 22 | __esModule: true, 23 | ...jest.requireActual("kodo-s3-adapter-sdk"), 24 | Downloader: mockedDownloader, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/qiniu-path.ts: -------------------------------------------------------------------------------- 1 | import { Path as QiniuPath } from "qiniu-path/dist/src/path"; 2 | import * as qiniuPathConvertor from "qiniu-path/dist/src/convert"; 3 | 4 | export function getMockDataOfQiniuPath(name: string, ext?: string) { 5 | const isDir = name.endsWith("/"); 6 | const fullName = isDir ? `${name}${ext ? "." + ext : ""}` : name; 7 | return new QiniuPath( 8 | "/", 9 | "", 10 | undefined, 11 | !isDir ? `.${ext}` : undefined, 12 | ["qiniu-client", isDir ? fullName.slice(0, -1) : fullName], 13 | isDir, 14 | ); 15 | } 16 | 17 | describe("test getMockDataOfQiniuPath", () => { 18 | it("create folder", function () { 19 | expect(getMockDataOfQiniuPath("createFolder/")) 20 | .toEqual( 21 | qiniuPathConvertor.fromQiniuPath("qiniu-client/createFolder/") 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/common/qiniu/_mock-helpers_/uploader.ts: -------------------------------------------------------------------------------- 1 | export function mockUploader() { 2 | const mockedUploader = jest.fn(); 3 | mockedUploader.constructor = mockedUploader; 4 | mockedUploader.prototype.putObjectFromFile = async function () { 5 | await new Promise(resolve => { 6 | setTimeout(resolve, 300) 7 | }); 8 | }; 9 | mockedUploader.prototype.putObjectFromFile = 10 | jest.fn(mockedUploader.prototype.putObjectFromFile); 11 | mockedUploader.prototype.abort = jest.fn(); 12 | 13 | return { 14 | __esModule: true, 15 | ...jest.requireActual("kodo-s3-adapter-sdk"), 16 | Uploader: mockedUploader, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/common/qiniu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export {default as createQiniuClient} from "./create-client"; 3 | -------------------------------------------------------------------------------- /src/common/qiniu/types.ts: -------------------------------------------------------------------------------- 1 | import {Region} from "kodo-s3-adapter-sdk"; 2 | 3 | export enum BackendMode { 4 | Kodo = "kodo", 5 | S3 = "s3", 6 | } 7 | 8 | interface ClientOptionsBase { 9 | accessKey: string, 10 | secretKey: string, 11 | sessionToken?: string, 12 | bucketNameId?: Record, 13 | ucUrl: string, 14 | backendMode: BackendMode, 15 | } 16 | 17 | export interface ClientOptions extends ClientOptionsBase { 18 | regions: Region[], 19 | } 20 | 21 | export interface ClientOptionsSerialized extends ClientOptionsBase { 22 | regions: { 23 | id: string, 24 | s3Id: string, 25 | label: string, 26 | s3Urls: string[], 27 | }[], 28 | } 29 | -------------------------------------------------------------------------------- /src/common/utility-types.ts: -------------------------------------------------------------------------------- 1 | type Primitive = null | undefined | string | number | boolean | symbol | bigint; 2 | 3 | type IsTuple> = number extends T["length"] ? false : true; 4 | type TupleKeys> = Exclude; 5 | 6 | declare type PathImpl = V extends Primitive ? `${K}` : `${K}` | `${K}.${PropsPath}`; 7 | 8 | // Example: PropsPath<{a: {b: string}}> = "a" | "a.b" 9 | export declare type PropsPath = T extends ReadonlyArray 10 | ? IsTuple extends true 11 | ? { 12 | [K in TupleKeys]-?: PathImpl; 13 | }[TupleKeys] 14 | : PathImpl 15 | : { 16 | [K in keyof T]-?: PathImpl; 17 | }[keyof T]; 18 | 19 | export declare type PathValue> = T extends any 20 | ? P extends `${infer K}.${infer R}` 21 | ? K extends keyof T 22 | ? R extends PropsPath 23 | ? PathValue 24 | : never 25 | : K extends number 26 | ? T extends ReadonlyArray 27 | ? PathValue> 28 | : never 29 | : never 30 | : P extends keyof T 31 | ? T[P] 32 | : P extends number 33 | ? T extends ReadonlyArray 34 | ? V 35 | : never 36 | : never 37 | : never; 38 | 39 | 40 | export declare type OptionalProps = Omit & Partial>; 41 | 42 | export declare type RequireProps = Omit & Required>; 43 | -------------------------------------------------------------------------------- /src/main/deep-links/index.ts: -------------------------------------------------------------------------------- 1 | import {DeepLinkRegister} from "./register"; 2 | 3 | export const deepLinkRegister = new DeepLinkRegister(); 4 | -------------------------------------------------------------------------------- /src/main/deep-links/register.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import {app} from "electron"; 4 | 5 | import {Handler, HandlerConstructable, SignInHandler} from "./handler"; 6 | import {Sender} from "@common/ipc-actions/types"; 7 | 8 | const SCHEME_NAME = "kodobrowser"; 9 | 10 | export class DeepLinkRegister { 11 | private handlers: Record = {}; 12 | private pendingDeepLink?: string; 13 | 14 | constructor( 15 | private handlerTypes: HandlerConstructable[] = [ 16 | SignInHandler, 17 | ], 18 | ) { 19 | } 20 | 21 | initialize() { 22 | switch (process.platform) { 23 | case "darwin": 24 | app.on("open-url", (_event, url) => { 25 | if (url && url.startsWith(SCHEME_NAME)) { 26 | this.handleDeepLink(url); 27 | } 28 | }); 29 | break; 30 | case "win32": 31 | case "linux": 32 | const lastArg = process.argv.pop(); 33 | if (lastArg && lastArg.startsWith(SCHEME_NAME)) { 34 | this.handleDeepLink(lastArg); 35 | } 36 | app.on('second-instance', (_event, commandLine, _workingDirectory) => { 37 | const url = commandLine.pop(); 38 | if (url && url.startsWith(SCHEME_NAME)) { 39 | this.handleDeepLink(url); 40 | } 41 | }); 42 | break; 43 | } 44 | 45 | app.once("ready", () => { 46 | if (process.defaultApp) { 47 | if (process.argv.length >= 2) { 48 | app.setAsDefaultProtocolClient(SCHEME_NAME, process.execPath, [path.resolve(process.argv[1])]); 49 | } 50 | } else { 51 | app.setAsDefaultProtocolClient(SCHEME_NAME); 52 | } 53 | }); 54 | } 55 | 56 | enable(sender: Sender, channel = "DeepLinks") { 57 | // deep link enabling; 58 | this.handlerTypes.forEach(t => { 59 | this.handlers[t.Host] = new t(sender, channel); 60 | }); 61 | if (this.pendingDeepLink) { 62 | // handle pending link 63 | this.handleDeepLink(this.pendingDeepLink); 64 | this.pendingDeepLink = undefined; 65 | } 66 | } 67 | 68 | disable() { 69 | this.handlers = {}; 70 | } 71 | 72 | handleDeepLink(href: string) { 73 | if (!Object.keys(this.handlers).length) { 74 | // no handlers, pending 75 | this.pendingDeepLink = href; 76 | return; 77 | } 78 | 79 | const url = new URL(href); 80 | // handle link by appropriate handler 81 | this.handlers[url.host]?.handle(href); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/kv-store/index.ts: -------------------------------------------------------------------------------- 1 | export {DataStore, getDataStoreOrCreate} from "./data-store"; 2 | -------------------------------------------------------------------------------- /src/main/kv-store/mem-table.ts: -------------------------------------------------------------------------------- 1 | import WriteAheadLogging from "./wal"; 2 | 3 | interface MemTableOptions{ 4 | wal: WriteAheadLogging | string, 5 | nodes?: Map, 6 | } 7 | 8 | export class MemTableReadonly { 9 | wal: WriteAheadLogging 10 | nodes: Map 11 | 12 | constructor({ 13 | wal, 14 | nodes, 15 | }: MemTableOptions) { 16 | if (typeof wal === "string") { 17 | this.wal = new WriteAheadLogging({ 18 | filePath: wal, 19 | }); 20 | } else { 21 | this.wal = wal; 22 | } 23 | this.nodes = nodes ?? new Map(); 24 | } 25 | 26 | async init({ 27 | nodes, 28 | }: { 29 | nodes?: Map, 30 | } = {}): Promise { 31 | if (!nodes) { 32 | const items = await this.wal.getAll(); 33 | this.nodes = new Map(Object.entries(items)); 34 | return; 35 | } 36 | this.nodes = nodes; 37 | } 38 | 39 | async close(): Promise { 40 | await this.wal.close(); 41 | this.nodes = new Map(); 42 | } 43 | 44 | has(key: string): boolean { 45 | return this.nodes.has(key); 46 | } 47 | 48 | get(key: string): T | undefined { 49 | const result = this.nodes.get(key); 50 | return result ? result : undefined; 51 | } 52 | 53 | get size(): number { 54 | return this.nodes.size; 55 | } 56 | 57 | * iter({ 58 | sorted = true 59 | } = {}): Generator<[string, T | null], void> { 60 | let entries: Iterable<[string, T | null]> = this.nodes.entries(); 61 | if (sorted) { 62 | entries = Array 63 | .from(this.nodes.entries()) 64 | .sort(([a], [b]) => { 65 | if (a === b) { 66 | return 0 67 | } else { 68 | return a < b ? -1 : 1 69 | } 70 | }); 71 | } 72 | for (const e of entries) { 73 | yield e; 74 | } 75 | } 76 | } 77 | 78 | export class MemTable extends MemTableReadonly { 79 | async set(key: string, val: T) { 80 | await this.wal.set(key, val); 81 | return this.nodes.set(key, val); 82 | } 83 | 84 | async del(key: string) { 85 | await this.wal.del(key); 86 | this.nodes.set(key, null); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/lockfile/index.ts: -------------------------------------------------------------------------------- 1 | import {lock, unlock, Options} from "lockfile" 2 | 3 | export function lockfile(path: string, opts?: Options): Promise { 4 | return new Promise((resolve, reject) => { 5 | const callback = (err: Error | null) => { 6 | if (err) { 7 | reject(err) 8 | return 9 | } 10 | resolve() 11 | } 12 | opts ? lock(path, opts, callback) : lock(path, callback) 13 | }) 14 | } 15 | 16 | export async function unlockFile(path: string): Promise { 17 | return new Promise((resolve, reject) => { 18 | unlock(path, (err: Error | null) => { 19 | if (err) { 20 | reject(err) 21 | return 22 | } 23 | resolve() 24 | }) 25 | }) 26 | } 27 | 28 | export async function withLockFile(path: string, fn: () => Promise, options?: Options) { 29 | await lockfile(path, options) 30 | try { 31 | await fn() 32 | } finally { 33 | await unlockFile(path) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/transfer-managers/boundary-const.ts: -------------------------------------------------------------------------------- 1 | import ByteSize from "@common/const/byte-size"; 2 | 3 | export const MIN_MULTIPART_SIZE = 4 * ByteSize.MB; 4 | export const MAX_MULTIPART_COUNT = 10000; 5 | -------------------------------------------------------------------------------- /src/main/transfer-managers/single-flight.ts: -------------------------------------------------------------------------------- 1 | type AsyncFunction = (...args: any[]) => Promise; 2 | 3 | // `Promise>>` is ugly to get the right type because of 4 | // the deferred resolving of conditional types involving unbound type parameters in TypeScript. 5 | // There are many issue with it, such as 6 | // https://github.com/microsoft/TypeScript/issues/43702 7 | // https://github.com/microsoft/TypeScript/issues/50251 8 | export default function singleFlight( 9 | fn: T, 10 | ): (key: string, ...args: Parameters) => Promise>> { 11 | const flightMap = new Map>>>(); 12 | return function (key: string, ...args: Parameters): Promise>> { 13 | const flight = flightMap.get(key); 14 | if (flight) { 15 | return flight; 16 | } 17 | const p = fn(...args); 18 | flightMap.set(key, p); 19 | p.then(() => { 20 | flightMap.delete(key); 21 | }); 22 | return p; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/app-life/before-quit.ts: -------------------------------------------------------------------------------- 1 | import {appPreferences} from "@renderer/modules/user-config-store"; 2 | 3 | export default function () { 4 | appPreferences.unwatchPersistence(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/renderer/app-life/before-start.ts: -------------------------------------------------------------------------------- 1 | import LaunchConfig from "@renderer/modules/launch-config"; 2 | import {appPreferences} from "@renderer/modules/user-config-store"; 3 | import * as LocalLogger from "@renderer/modules/local-logger" 4 | import * as I18n from "@renderer/modules/i18n"; 5 | import * as auth from "@renderer/modules/auth"; 6 | import {shouldMigrate, getMigrator} from "@renderer/modules/update-app"; 7 | 8 | export default async function (): Promise { 9 | // try to migrate 10 | const shouldMigrateRes = await shouldMigrate(); 11 | if (shouldMigrateRes === "upgrade") { 12 | const migrator = await getMigrator(); 13 | await migrator.upgrade(); 14 | } 15 | 16 | // setup launch config 17 | new LaunchConfig().setup(); 18 | 19 | // load application preferences from persistence 20 | await appPreferences.loadFromPersistence(); 21 | appPreferences.watchPersistence(); 22 | 23 | // initial application preferences 24 | LocalLogger.setLevel(appPreferences.get("logLevel")); 25 | await I18n.setLang(appPreferences.get("language")); 26 | 27 | // initial authorization 28 | await auth.loadPersistence(); 29 | }; 30 | -------------------------------------------------------------------------------- /src/renderer/app-life/index.ts: -------------------------------------------------------------------------------- 1 | export {default as beforeStart} from './before-start'; 2 | export {default as beforeQuit} from './before-quit'; 3 | -------------------------------------------------------------------------------- /src/renderer/components/base-grid/base-grid.scss: -------------------------------------------------------------------------------- 1 | .base-grid { 2 | --bt-cell-accent-bg: transparent; 3 | --bg-cell-hover-bg: rgba(0, 0, 0, 0.075); 4 | padding-bottom: 1.4rem; 5 | 6 | & .base-cell { 7 | --bs-card-bg: transparent; 8 | cursor: pointer; 9 | box-shadow: inset 0 0 0 9999px var(--bt-cell-accent-bg); 10 | 11 | &:hover { 12 | --bt-cell-accent-bg: var(--bg-cell-hover-bg); 13 | } 14 | } 15 | 16 | .base-overlay { 17 | position: absolute; 18 | inset: 0 0 0 0; 19 | pointer-events: none; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/components/batch-progress/batch-progress.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Button, ProgressBar} from "react-bootstrap"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | 6 | import {BatchTaskStatus} from "./types"; 7 | 8 | interface BatchProgressProps { 9 | status: BatchTaskStatus, 10 | total: number, 11 | finished: number, 12 | errored: number, 13 | isPercent?: boolean, 14 | onClickInterrupt?: () => void, 15 | } 16 | 17 | const BatchProgress: React.FC = (props) => { 18 | const {translate} = useI18n(); 19 | 20 | const { 21 | status, 22 | total, 23 | finished, 24 | errored, 25 | onClickInterrupt, 26 | isPercent = false, 27 | } = props; 28 | 29 | // calculate error bar min-width(1%) to prevent not visible if too few errors 30 | const hasError = errored > 0; 31 | const erroredPercent = hasError ? Math.max(errored * 100 / total, 1) : 0; 32 | // fallback 0, because it will get NaN, if total is 0. 33 | const finishedPercent = Math.min(finished * 100 / total, hasError ? 99 : 100) || 0; 34 | 35 | return ( 36 |
37 | 38 | 44 | 51 | 52 |
53 |
54 | { 55 | isPercent 56 | ? `${finishedPercent.toFixed(2)}%` 57 | : `${finished} / ${total}` 58 | } 59 |
60 | { 61 | !onClickInterrupt || status !== BatchTaskStatus.Running 62 | ? null 63 | : 71 | } 72 |
73 |
74 | ); 75 | }; 76 | 77 | export default BatchProgress; 78 | -------------------------------------------------------------------------------- /src/renderer/components/batch-progress/error-file-list.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {OverlayTrigger, Tooltip} from "react-bootstrap"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | import {FileItem} from "@renderer/modules/qiniu-client"; 6 | 7 | export interface ErroredFileOperation { 8 | fileType: FileItem.ItemType, 9 | path: string, 10 | errorMessage: string, 11 | }; 12 | 13 | interface ErroredFileOperationListProps { 14 | data: ErroredFileOperation[], 15 | errorLegend?: string, 16 | maxLength?: number, 17 | } 18 | 19 | const ErrorFileList: React.FC = (props) => { 20 | const {translate} = useI18n(); 21 | 22 | const { 23 | data: erroredFileList, 24 | errorLegend = translate("common.errored"), 25 | maxLength = 10, 26 | } = props; 27 | 28 | return ( 29 |
30 |
31 | {errorLegend} 32 |
33 |
    34 | { 35 | erroredFileList.slice(0, maxLength).map(erroredFile => 36 |
  • 37 | { 38 | erroredFile.fileType === FileItem.ItemType.Directory 39 | ? 40 | : 41 | } 42 | {erroredFile.path} 43 | 46 | {erroredFile.errorMessage} 47 | 48 | } 49 | > 50 | 51 | 52 |
  • 53 | ) 54 | } 55 | { 56 | erroredFileList.length > maxLength 57 | ?
  • ...
  • 58 | : null 59 | } 60 |
61 |
62 | ); 63 | } 64 | 65 | export default ErrorFileList; 66 | -------------------------------------------------------------------------------- /src/renderer/components/batch-progress/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export {default as BatchProgress} from "./batch-progress" 3 | export * from "./use-batch-progress"; 4 | export {default as useBatchProgress} from "./use-batch-progress"; 5 | export * from "./error-file-list"; 6 | export {default as ErrorFileList} from "./error-file-list"; 7 | -------------------------------------------------------------------------------- /src/renderer/components/batch-progress/types.ts: -------------------------------------------------------------------------------- 1 | export enum BatchTaskStatus { 2 | Standby = "standby", 3 | Running = "running", 4 | Paused = "Paused", 5 | Ended = "ended", 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/batch-progress/use-batch-progress.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | 3 | import {BatchTaskStatus} from "./types"; 4 | 5 | export interface BatchProgressState { 6 | status: BatchTaskStatus, 7 | total: number, 8 | finished: number, 9 | errored: number, 10 | } 11 | 12 | const defaultBatchProgressState: BatchProgressState = { 13 | status: BatchTaskStatus.Standby, 14 | total: 0, 15 | finished: 0, 16 | errored: 0, 17 | } 18 | 19 | const useBatchProgress = ( 20 | initState: BatchProgressState = defaultBatchProgressState 21 | ): [BatchProgressState, typeof setBatchProgressState] => { 22 | const [batchProgressState, setState] = useState(initState); 23 | function setBatchProgressState( 24 | state: Partial | ((state: BatchProgressState) => Partial) 25 | ) { 26 | if (typeof state === "function") { 27 | setState(s => ({ 28 | ...s, 29 | ...state(s) 30 | })); 31 | return; 32 | } 33 | setState(s => ({ 34 | ...s, 35 | ...state, 36 | })); 37 | } 38 | return [batchProgressState, setBatchProgressState]; 39 | } 40 | 41 | export default useBatchProgress; 42 | -------------------------------------------------------------------------------- /src/renderer/components/drop-zone/drop-zone.scss: -------------------------------------------------------------------------------- 1 | .drop-zone { 2 | border: .5rem dashed var(--bs-border-color); 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | 7 | & * { 8 | pointer-events: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/components/empty-holder/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {useI18n} from "@renderer/modules/i18n"; 4 | 5 | import LoadingHolder from "../loading-holder"; 6 | 7 | interface EmptyHolderProps { 8 | icon?: React.ReactElement, 9 | subtitle?: React.ReactElement, 10 | loading?: boolean, 11 | col?: number, 12 | } 13 | 14 | const EmptyHolder: React.FC = ({ 15 | icon = null, 16 | subtitle = null, 17 | loading, 18 | col, 19 | }) => { 20 | const {translate} = useI18n(); 21 | 22 | if (loading) { 23 | return ( 24 | 25 | ); 26 | } 27 | 28 | if (col) { 29 | return ( 30 | 31 | 32 |
33 | {icon} 34 | {translate("common.empty")} 35 | {subtitle} 36 |
37 | 38 | 39 | ); 40 | } 41 | 42 | return ( 43 |
44 | {icon} 45 | {translate("common.empty")} 46 | {subtitle} 47 |
48 | ); 49 | }; 50 | 51 | export default EmptyHolder; 52 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/expire-after-field.tsx: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from "react"; 2 | import {Control, useController} from "react-hook-form"; 3 | import {Form, InputGroup} from "react-bootstrap"; 4 | 5 | import Duration from "@common/const/duration"; 6 | import {Translate, useI18n} from "@renderer/modules/i18n"; 7 | 8 | import {GenerateLinkFormData} from "./types"; 9 | 10 | export const DEFAULT_EXPIRE_AFTER = 600; 11 | 12 | interface ExpireAfterFieldProps { 13 | control: Control, 14 | maxValue: number, 15 | } 16 | 17 | const ExpireAfterField: React.FC = ({ 18 | control, 19 | maxValue, 20 | }) => { 21 | const {translate} = useI18n(); 22 | 23 | const {field, fieldState} = useController({ 24 | control, 25 | name: "expireAfter", 26 | defaultValue: DEFAULT_EXPIRE_AFTER, 27 | rules: { 28 | required: true, 29 | min: 1, 30 | max: maxValue / Duration.Second, 31 | }, 32 | }); 33 | 34 | return ( 35 | 36 | 37 | {translate("forms.generateLink.expireAfter.label")} 38 | 39 |
40 | 41 | field.onChange(+e.target.value)} 44 | type="number" 45 | isInvalid={Boolean(fieldState.error)} 46 | /> 47 | 48 | {translate("forms.generateLink.expireAfter.suffix")} 49 | 50 | 51 | 58 | 65 | 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default ExpireAfterField; 72 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/file-link-field.tsx: -------------------------------------------------------------------------------- 1 | import {clipboard} from "electron"; 2 | 3 | import React, {Fragment} from "react"; 4 | import {Button, Form, InputGroup} from "react-bootstrap"; 5 | import {toast} from "react-hot-toast"; 6 | 7 | import {useI18n} from "@renderer/modules/i18n"; 8 | 9 | interface FileLinkFieldProps { 10 | fileLink: string, 11 | loading: boolean, 12 | } 13 | 14 | const FileLinkField: React.FC = ({ 15 | fileLink, 16 | loading, 17 | }) => { 18 | const {translate} = useI18n(); 19 | 20 | const handleCopyFileLink = () => { 21 | clipboard.writeText(fileLink); 22 | toast.success(translate("forms.generateLink.fileLink.copied")); 23 | } 24 | 25 | return ( 26 | 30 | 31 | {translate("forms.generateLink.fileLink.label")} 32 | 33 | 34 | 39 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default FileLinkField; 56 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/file-name-field.tsx: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from "react"; 2 | import {Form} from "react-bootstrap"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | 6 | interface FileNameFieldProps { 7 | fileName: string, 8 | } 9 | 10 | const FileNameField: React.FC = ({ 11 | fileName, 12 | }) => { 13 | const {translate} = useI18n(); 14 | 15 | return ( 16 | 17 | 18 | {translate("forms.generateLink.fileName.label")} 19 | 20 |
21 | 26 |
27 |
28 | ); 29 | }; 30 | 31 | export default FileNameField; 32 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/generate-link-form.tsx: -------------------------------------------------------------------------------- 1 | import React, {PropsWithChildren} from "react"; 2 | import {Button, Form, Spinner} from "react-bootstrap"; 3 | import {UseFormHandleSubmit} from "react-hook-form"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | 7 | import {GenerateLinkFormData} from "./types"; 8 | 9 | interface GenerateLinkFormProps { 10 | onSubmit?: ReturnType>, 11 | onChange?: () => void, 12 | 13 | isValid?: boolean, 14 | isSubmitting?: boolean, 15 | 16 | // portal 17 | submitButtonPortal?: React.FC, 18 | } 19 | 20 | const GenerateLinkForm: React.FC> = ({ 21 | onChange, 22 | onSubmit, 23 | 24 | isValid = true, 25 | isSubmitting = false, 26 | 27 | submitButtonPortal: SubmitButtonPortal, 28 | 29 | children, 30 | }) => { 31 | const {translate} = useI18n(); 32 | 33 | return ( 34 | <> 35 |
40 |
43 |
47 | {children} 48 |
49 |
50 |
51 | { 52 | SubmitButtonPortal 53 | ? 54 | 69 | 70 | : null 71 | } 72 | 73 | ); 74 | }; 75 | 76 | export default GenerateLinkForm; 77 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export {default as GenerateLinkForm} from './generate-link-form'; 3 | export {default as DomainNameField} from './domain-name-field'; 4 | export {default as ExpireAfterField} from './expire-after-field'; 5 | export * from"./expire-after-field"; 6 | export {default as FileLinkField} from './file-link-field'; 7 | export {default as FileNameField} from './file-name-field'; 8 | -------------------------------------------------------------------------------- /src/renderer/components/forms/generate-link-form/types.ts: -------------------------------------------------------------------------------- 1 | import {DomainAdapter} from "@renderer/modules/qiniu-client-hooks"; 2 | 3 | export interface GenerateLinkFormData { 4 | domain: DomainAdapter | undefined, 5 | expireAfter: number, // seconds 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./change-storage-class-form"; 2 | export {default as ChangeStorageClassForm} from "./change-storage-class-form"; 3 | export * from "./restore-form"; 4 | export {default as RestoreForm} from "./restore-form"; 5 | -------------------------------------------------------------------------------- /src/renderer/components/forms/types.ts: -------------------------------------------------------------------------------- 1 | export interface FormComponentBaseProps { 2 | name: string, 3 | onChange?: (val?: T) => void, 4 | onBlur?: (val?: T) => void, 5 | required?: boolean, 6 | disabled?: boolean, 7 | 8 | value?: T, 9 | 10 | isInvalid?: boolean, 11 | } 12 | 13 | export interface FormSelectProps extends FormComponentBaseProps { 14 | options: T[], 15 | } 16 | 17 | export interface FormSwitchProps extends FormComponentBaseProps { 18 | label: string, 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/components/lite-confirm/index.tsx: -------------------------------------------------------------------------------- 1 | export {default as usePromiseConfirm} from "./use-promise-confirm"; 2 | -------------------------------------------------------------------------------- /src/renderer/components/lite-confirm/use-promise-confirm.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | 3 | type ConfirmState = { 4 | isShowConfirm: true, 5 | handleConfirm: (ok: boolean) => void, 6 | } | { 7 | isShowConfirm: false, 8 | handleConfirm: undefined, 9 | } 10 | 11 | type ToggleConfirmFunctionReturnType = 12 | T extends true ? Promise : 13 | T extends false ? undefined : 14 | never; 15 | 16 | export default function usePromiseConfirm(): [ConfirmState, typeof toggleConfirm] { 17 | const [ 18 | confirmState, 19 | setConfirmState, 20 | ] = useState({isShowConfirm: false, handleConfirm: undefined}); 21 | 22 | function toggleConfirm(open: T): ToggleConfirmFunctionReturnType { 23 | if (!open) { 24 | setConfirmState({ 25 | isShowConfirm: false, 26 | handleConfirm: undefined, 27 | }); 28 | return undefined as ToggleConfirmFunctionReturnType; 29 | } 30 | return new Promise(resolve => { 31 | setConfirmState({ 32 | isShowConfirm: true, 33 | handleConfirm: resolve, 34 | }); 35 | }) as ToggleConfirmFunctionReturnType; 36 | } 37 | 38 | return [confirmState, toggleConfirm]; 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/components/loading-holder/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Spinner} from "react-bootstrap"; 3 | import classNames from "classnames"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | 7 | interface LoadingHolderProps { 8 | className?: string, 9 | col?: number, 10 | horizontal?: boolean, 11 | size?: "sm" 12 | text?: string, 13 | } 14 | 15 | const LoadingHolder: React.FC = ({ 16 | className, 17 | col, 18 | horizontal, 19 | size, 20 | text, 21 | }) => { 22 | const {translate} = useI18n(); 23 | 24 | if (col) { 25 | return ( 26 | 27 | 28 | 29 |
{translate("common.loading")}
30 | 31 | 32 | ) 33 | } 34 | 35 | return ( 36 |
43 | 44 |
{text ?? translate("common.loading")}
45 |
46 | ); 47 | }; 48 | 49 | export default LoadingHolder; 50 | -------------------------------------------------------------------------------- /src/renderer/components/modals/common/confirm-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import {Button, Modal, ModalProps} from "react-bootstrap"; 3 | import {ButtonVariant} from "react-bootstrap/types"; 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | 6 | interface ConfirmModalProps { 7 | title: React.ReactNode, 8 | content: React.ReactNode, 9 | okText?: string, 10 | okClassName?: string, 11 | okVariant?: ButtonVariant, 12 | cancelText?: string, 13 | cancelClassName?: string, 14 | cancelVariant?: ButtonVariant, 15 | onOk: () => Promise | void, 16 | } 17 | 18 | const ConfirmModal: React.FC = ({ 19 | title, 20 | content, 21 | okText, 22 | okClassName, 23 | okVariant = "primary", 24 | cancelText, 25 | cancelClassName, 26 | cancelVariant = "light", 27 | onOk, 28 | ...modalProps 29 | }) => { 30 | const {translate} = useI18n(); 31 | 32 | const [isSubmitting, setIsSubmitting] = useState(false); 33 | 34 | const handleClickOk = () => { 35 | setIsSubmitting(true); 36 | const okRes = onOk(); 37 | if (!okRes) { 38 | modalProps.onHide?.(); 39 | setIsSubmitting(false); 40 | return; 41 | } 42 | okRes 43 | .then(() => { 44 | modalProps.onHide?.(); 45 | }) 46 | .finally(() => { 47 | setIsSubmitting(false); 48 | }); 49 | } 50 | 51 | return ( 52 | 53 | 54 | 55 | {title} 56 | 57 | 58 | 59 | {content} 60 | 61 | 62 | 71 | 79 | 80 | 81 | ); 82 | }; 83 | 84 | export default ConfirmModal; 85 | -------------------------------------------------------------------------------- /src/renderer/components/modals/file/common/file-list.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {FileItem} from "@renderer/modules/qiniu-client"; 4 | 5 | interface FileListItemProps { 6 | data: FileItem.Item, 7 | } 8 | 9 | const FileListItem: React.FC = ({ 10 | data, 11 | }) => { 12 | switch (data.itemType) { 13 | case FileItem.ItemType.Directory: 14 | return ( 15 |
  • 16 | 17 | {data.name} 18 |
  • 19 | ); 20 | case FileItem.ItemType.File: 21 | return ( 22 |
  • 23 | 24 | {data.name} 25 |
  • 26 | ); 27 | case FileItem.ItemType.Prefix: 28 | return ( 29 |
  • 30 | {data.path.toString()} 31 |
  • 32 | ); 33 | } 34 | }; 35 | 36 | interface FileListProps { 37 | className?: string, 38 | data: FileItem.Item[], 39 | description?: React.ReactNode, 40 | prefixDescription?: React.ReactNode, 41 | } 42 | 43 | const FileList: React.FC = ({ 44 | className, 45 | data, 46 | description, 47 | prefixDescription, 48 | }) => { 49 | const prefixes: FileItem.Item[] = data.filter(FileItem.isItemPrefix); 50 | const prefixPaths = prefixes.map(p => p.path.toString()); 51 | const otherItems = data.filter(i => 52 | !FileItem.isItemPrefix(i) && 53 | !prefixPaths.some(p => 54 | i.path.toString().startsWith(p) 55 | ) 56 | ); 57 | 58 | return ( 59 |
    60 | { 61 | prefixes?.length > 0 && 62 |
    63 | {prefixDescription} 64 |
      65 | { 66 | prefixes.map(fileItem => ( 67 | 68 | )) 69 | } 70 |
    71 |
    72 | } 73 | { 74 | otherItems?.length > 0 && 75 |
    76 | {description} 77 |
      78 | { 79 | otherItems.map(fileItem => ( 80 | 81 | )) 82 | } 83 |
    84 |
    85 | } 86 |
    87 | ) 88 | } 89 | 90 | export default FileList; 91 | -------------------------------------------------------------------------------- /src/renderer/components/modals/file/types.ts: -------------------------------------------------------------------------------- 1 | interface OperationDoneRecallArgs { 2 | originBasePath: string, 3 | } 4 | 5 | export type OperationDoneRecallFn = (args: OperationDoneRecallArgs) => void; 6 | -------------------------------------------------------------------------------- /src/renderer/components/modals/file/utils.ts: -------------------------------------------------------------------------------- 1 | import {FileItem} from "@renderer/modules/qiniu-client"; 2 | 3 | export function isRecursiveDirectory(file: FileItem.Item, destPath: string): boolean { 4 | if (FileItem.isItemFile(file)) { 5 | return false; 6 | } 7 | return destPath.startsWith(file.path.toString()); 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/about/about.scss: -------------------------------------------------------------------------------- 1 | .about-brand { 2 | & .app-name { 3 | margin-left: .5rem; 4 | font-weight: bolder; 5 | } 6 | 7 | & .app-version { 8 | margin-left: .5em; 9 | font-size: small; 10 | color: #FF9900; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/about/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Modal, ModalProps} from "react-bootstrap"; 3 | import {shell} from "electron" 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import * as APP_INFO from "@common/const/app-config"; 7 | 8 | import PreviewUpdate from "./preview-update"; 9 | import "./about.scss"; 10 | 11 | const About: React.FC = (modalProps) => { 12 | const {translate} = useI18n(); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | {translate("modals.about.title")} 20 | 21 | 22 | 23 |
    24 | logo 31 | {translate("common.kodoBrowser")} 32 | v{APP_INFO.app.version} 33 |
    34 |
    35 | {translate("modals.about.openSourceAddress")} 36 | shell.openExternal("https://github.com/qiniu/kodo-browser")} 40 | onKeyUp={e => e.code === "Space" && shell.openExternal("https://github.com/qiniu/kodo-browser")} 41 | > 42 | https://github.com/qiniu/kodo-browser 43 | 44 |
    45 |
    46 | 47 |
    48 |
    49 | ) 50 | }; 51 | 52 | export default About; 53 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/about/preview-update.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | 3 | import {app} from "@common/const/app-config"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import MarkdownView from "@renderer/modules/markdown"; 7 | import {fetchLatestVersion, fetchReleaseNote} from "@renderer/modules/update-app"; 8 | import LoadingHolder from "@renderer/components/loading-holder"; 9 | 10 | import DownloadUpdate from "./download-update"; 11 | 12 | const PreviewUpdate: React.FC = ({}) => { 13 | const {translate} = useI18n(); 14 | 15 | // load the latest version release note 16 | const [loading, setLoading] = useState(true); 17 | 18 | // markdown view 19 | const [releaseNote, setReleaseNote] = useState(); 20 | const [latestVersion, setLatestVersion] = useState(null); 21 | 22 | useEffect(() => { 23 | fetchLatestVersion(app.version) 24 | .then(latestVersion => { 25 | setLatestVersion(latestVersion); 26 | if (!latestVersion) { 27 | return; 28 | } 29 | return fetchReleaseNote(latestVersion); 30 | }) 31 | .then(releaseText => { 32 | setReleaseNote(releaseText); 33 | }) 34 | .finally(() => { 35 | setLoading(false); 36 | }); 37 | }, []); 38 | 39 | // render 40 | if (loading) { 41 | return ( 42 | 45 | ); 46 | } 47 | 48 | if (!latestVersion) { 49 | return ( 50 |
    51 | {translate("modals.about.updateApp.alreadyLatest")} 52 |
    53 | ); 54 | } 55 | 56 | return ( 57 | <> 58 | 59 |
    60 | {translate("modals.about.updateApp.changeLogsTitle")} 61 |
    62 | 65 |
    66 |
    67 | 68 | ); 69 | }; 70 | 71 | export default PreviewUpdate; 72 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/ak-history/ak-table-row.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import {Button} from "react-bootstrap"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | 6 | import {AkItem} from "@renderer/modules/auth"; 7 | 8 | interface AkTableRowProps { 9 | data: AkItem, 10 | isCurrentUser?: boolean, 11 | onActive?: (item: AkItem) => void, 12 | onDelete?: (item: AkItem) => Promise, 13 | } 14 | 15 | const AkTableRow: React.FC = ({ 16 | data, 17 | isCurrentUser, 18 | onActive, 19 | onDelete, 20 | }) => { 21 | const {translate} = useI18n(); 22 | const [deleting, setDeleting] = useState(false); 23 | 24 | const handleDelete = async (item: AkItem) => { 25 | if (deleting || !onDelete) { 26 | return; 27 | } 28 | setDeleting(true); 29 | await onDelete(item); 30 | setDeleting(false); 31 | } 32 | 33 | return ( 34 | 35 | {data.endpointType} 36 | {data.accessKey} 37 | {data.accessSecret} 38 | {data.description} 39 | 40 | { 41 | !onActive 42 | ? null 43 | : isCurrentUser 44 | ? {translate("modals.akHistory.currentUser")} 45 | : 48 | } 49 | { 50 | !onDelete 51 | ? null 52 | : 55 | } 56 | 57 | 58 | ) 59 | } 60 | 61 | export default AkTableRow; 62 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/bookmark-manager/bookmark-table-row.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Button} from "react-bootstrap"; 3 | import moment from "moment"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import {BookmarkItem} from "@renderer/modules/user-config-store"; 7 | 8 | interface BookmarkTableRowProps { 9 | data: BookmarkItem, 10 | onActive?: (item: BookmarkItem) => void, 11 | onDelete?: (item: BookmarkItem) => void, 12 | } 13 | 14 | const BookmarkTableRow: React.FC = ({ 15 | data, 16 | onActive, 17 | onDelete, 18 | }) => { 19 | const {translate} = useI18n(); 20 | 21 | return ( 22 | 23 | 24 | onActive?.(data)} 27 | > 28 | {data.protocol}{data.path} 29 | 30 | 31 | 32 | {moment(data.timestamp).format("YYYY-MM-DD HH:mm:ss")} 33 | 34 | 35 | { 36 | onDelete 37 | ? 44 | : null 45 | } 46 | 47 | 48 | ) 49 | } 50 | 51 | export default BookmarkTableRow; 52 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/bookmark-manager/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Modal, ModalProps, Table} from "react-bootstrap"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | import {useAuth} from "@renderer/modules/auth"; 6 | import {BookmarkItem, useBookmarkPath} from "@renderer/modules/user-config-store"; 7 | 8 | import EmptyHolder from "@renderer/components/empty-holder"; 9 | 10 | import BookmarkTableRow from "./bookmark-table-row"; 11 | 12 | interface BookmarkManagerProps { 13 | onActiveBookmark: (bookmark: BookmarkItem) => void, 14 | } 15 | 16 | const BookmarkManager: React.FC = ({ 17 | onActiveBookmark, 18 | ...modalProps 19 | }) => { 20 | const {translate} = useI18n(); 21 | const {currentUser} = useAuth(); 22 | 23 | const { 24 | bookmarkPathData, 25 | deleteBookmark, 26 | } = useBookmarkPath(currentUser); 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | {translate("modals.bookmarkManager.title")} 34 | 35 | 36 | 37 |
    38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | { 48 | bookmarkPathData.list.length 49 | ? bookmarkPathData.list.map(item => ( 50 | { 54 | onActiveBookmark(bookmark); 55 | modalProps.onHide?.(); 56 | }} 57 | onDelete={itemToDelete => { 58 | deleteBookmark(itemToDelete); 59 | }} 60 | /> 61 | )) 62 | : 63 | } 64 | 65 |
    {translate("modals.bookmarkManager.table.url")}{translate("modals.bookmarkManager.table.createTime")}{translate("modals.bookmarkManager.table.operation")}
    66 |
    67 |
    68 |
    69 | ) 70 | }; 71 | 72 | export default BookmarkManager; 73 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/release-note-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {Modal, ModalProps} from "react-bootstrap"; 3 | 4 | import {app} from "@common/const/app-config"; 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import {fetchReleaseNote} from "@renderer/modules/update-app"; 7 | import MarkdownView from "@renderer/modules/markdown"; 8 | 9 | import LoadingHolder from "@renderer/components/loading-holder"; 10 | 11 | interface ReleaseNoteModalProps { 12 | version?: string, 13 | } 14 | 15 | const ReleaseNoteModal: React.FC = ({ 16 | version = app.version, 17 | ...modalProps 18 | }) => { 19 | const {translate} = useI18n(); 20 | 21 | const [loading, setLoading] = useState(true); 22 | const [releaseNote, setReleaseNote] = useState(); 23 | 24 | useEffect(() => { 25 | if (modalProps.show) { 26 | setLoading(true); 27 | fetchReleaseNote(version) 28 | .then(releaseText => { 29 | setReleaseNote(releaseText); 30 | }) 31 | .finally(() => { 32 | setLoading(false); 33 | }); 34 | } 35 | }, [modalProps.show]) 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | v{version} {translate("modals.releaseNote.title")} 43 | 44 | 45 | 46 |
    47 | { 48 | loading 49 | ? 50 | : 53 | } 54 |
    55 |
    56 |
    57 | ); 58 | }; 59 | 60 | export default ReleaseNoteModal; 61 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/settings-modal/fields-external-path.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Col, Form, Row} from "react-bootstrap"; 3 | import {useFormContext} from "react-hook-form"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import {AppPreferencesData} from "@renderer/modules/user-config-store"; 7 | 8 | const FieldsExternalPath: React.FC = () => { 9 | const {translate} = useI18n(); 10 | const {register} = useFormContext(); 11 | 12 | return ( 13 |
    14 | {translate("modals.settings.externalPath.legend")} 15 | 16 | 17 | {translate("modals.settings.externalPath.form.enabled.label")} 18 | 19 | 20 | 24 | 25 | 26 |
    27 | ) 28 | }; 29 | 30 | export default FieldsExternalPath; 31 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/settings-modal/settings.scss: -------------------------------------------------------------------------------- 1 | .settings-form { 2 | position: relative; 3 | 4 | & legend { 5 | position: sticky; 6 | top: 0; 7 | background-color: var(--bs-body-bg); 8 | font-size: large; 9 | border-bottom: 1px solid var(--bs-border-color); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/components/modals/general/settings-modal/types.ts: -------------------------------------------------------------------------------- 1 | import {LangName} from "@renderer/modules/i18n"; 2 | 3 | export interface SettingsUploadFormData { 4 | enabledResumeUpload: boolean, 5 | multipartUploadThreshold: number, 6 | multipartUploadPartSize: number, 7 | maxUploadConcurrency: number, 8 | enabledUploadSpeedLimit: boolean, 9 | uploadSpeedLimit: number, 10 | } 11 | 12 | export interface SettingsDownloadFormData { 13 | enabledResumeDownload: boolean, 14 | multipartDownloadThreshold: number, 15 | multipartDownloadPartSize: number, 16 | maxDownloadConcurrency: number, 17 | enabledDownloadSpeedLimit: boolean, 18 | downloadSpeedLimit: number, 19 | } 20 | 21 | export interface ExternalPathFormData { 22 | enabledExternalPath: boolean, 23 | } 24 | 25 | export interface OthersFormData { 26 | enabledDebugLog: boolean, 27 | enabledLoadFilesOnTouchEnd: boolean, 28 | loadFilesNumberPerPage: number, 29 | enabledAutoUpdateApp: boolean, 30 | language: LangName, 31 | } 32 | 33 | export type SettingsFormData = 34 | SettingsUploadFormData 35 | & SettingsDownloadFormData 36 | & ExternalPathFormData 37 | & OthersFormData; 38 | -------------------------------------------------------------------------------- /src/renderer/components/modals/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export {default as useDisplayModal} from "./use-display-modal"; 2 | export {default as useSubmitModal} from "./use-submit-modal"; 3 | export {default as useIsShowAnyModal} from "./use-is-show-any-modal"; 4 | -------------------------------------------------------------------------------- /src/renderer/components/modals/hooks/modal-counter-store.ts: -------------------------------------------------------------------------------- 1 | const displayModalCounterStore = { 2 | data: { 3 | showed: 0, 4 | }, 5 | listeners: new Set<() => void>(), 6 | subscribe(l: () => void) { 7 | displayModalCounterStore.listeners.add(l); 8 | return () => displayModalCounterStore.listeners.delete(l); 9 | }, 10 | getSnapshot() { 11 | return displayModalCounterStore.data; 12 | }, 13 | dispatch(action: "open" | "close") { 14 | switch (action) { 15 | case "open": 16 | displayModalCounterStore.data.showed += 1; 17 | break; 18 | case "close": 19 | displayModalCounterStore.data.showed -= 1; 20 | break; 21 | default: 22 | return; 23 | } 24 | displayModalCounterStore.listeners.forEach(l => l()); 25 | }, 26 | }; 27 | 28 | export default displayModalCounterStore; 29 | -------------------------------------------------------------------------------- /src/renderer/components/modals/hooks/use-display-modal.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | 3 | import displayModalCounterStore from "./modal-counter-store"; 4 | 5 | interface DisplayModalState { 6 | show: boolean, 7 | data: T, 8 | } 9 | 10 | interface DisplayModalFns { 11 | showModal: (...data: T extends undefined ? [] : [T]) => void, 12 | hideModal: (...data: T extends undefined ? [] : [T]) => void, 13 | toggleModal: (...data: T extends undefined ? [] : [T]) => void, 14 | } 15 | 16 | function useDisplayModal(initialData: T): [DisplayModalState, DisplayModalFns] 17 | function useDisplayModal(): [DisplayModalState, DisplayModalFns] 18 | function useDisplayModal(initialData?: T): [DisplayModalState, DisplayModalFns] { 19 | const [ 20 | { 21 | show, 22 | data, 23 | }, 24 | setShowWithData, 25 | ] = useState({ 26 | show: false, 27 | data: initialData, 28 | }); 29 | 30 | const showModal = (data?: T) => { 31 | displayModalCounterStore.dispatch("open"); 32 | return setShowWithData(v => ({ 33 | show: true, 34 | data: data === undefined 35 | ? v.data 36 | : { 37 | ...v.data, 38 | ...data, 39 | }, 40 | })); 41 | }; 42 | const hideModal = (data?: T) => { 43 | displayModalCounterStore.dispatch("close"); 44 | return setShowWithData(v => ({ 45 | show: false, 46 | data: data === undefined 47 | ? v.data 48 | : { 49 | ...v.data, 50 | ...data, 51 | }, 52 | })); 53 | }; 54 | const toggleModal = (data?: T) => setShowWithData(v => { 55 | displayModalCounterStore.dispatch(!v.show ? "close" : "open"); 56 | return { 57 | show: !v.show, 58 | data: data === undefined 59 | ? v.data 60 | : { 61 | ...v.data, 62 | ...data, 63 | }, 64 | } 65 | }); 66 | 67 | return [ 68 | { 69 | show, 70 | data, 71 | }, 72 | { 73 | showModal, 74 | hideModal, 75 | toggleModal, 76 | }, 77 | ]; 78 | } 79 | 80 | export default useDisplayModal; 81 | -------------------------------------------------------------------------------- /src/renderer/components/modals/hooks/use-is-show-any-modal.ts: -------------------------------------------------------------------------------- 1 | import {useSyncExternalStore} from "react"; 2 | 3 | import displayModalCounterStore from "@renderer/components/modals/hooks/modal-counter-store"; 4 | 5 | const useIsShowAnyModal = () => { 6 | const displayModalShowed = useSyncExternalStore( 7 | displayModalCounterStore.subscribe, 8 | () => displayModalCounterStore.getSnapshot().showed 9 | ); 10 | return displayModalShowed > 0; 11 | }; 12 | 13 | export default useIsShowAnyModal; 14 | -------------------------------------------------------------------------------- /src/renderer/components/modals/hooks/use-submit-modal.ts: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | 3 | type AsyncFunction = (...args: any[]) => Promise | void; 4 | 5 | type UseHandleSubmit = (fn: T, ...args: Parameters) => (e?: React.BaseSyntheticEvent) => (Promise>> | void); 6 | 7 | type SubmitModalHook = () => { 8 | state: { 9 | isSubmitting: boolean, 10 | // isSubmitSuccessful: boolean, 11 | } 12 | handleSubmit: UseHandleSubmit, 13 | // reset, 14 | }; 15 | 16 | const useSubmitModal: SubmitModalHook = () => { 17 | const [isSubmitting, setIsSubmitting] = useState(false); 18 | 19 | const handleSubmit: UseHandleSubmit = (fn, ...args) => { 20 | return (e) => { 21 | e?.preventDefault(); 22 | 23 | const p = fn(...args); 24 | if (p) { 25 | setIsSubmitting(true); 26 | p.finally(() => { 27 | setIsSubmitting(false); 28 | }); 29 | } 30 | return p; 31 | } 32 | } 33 | 34 | return { 35 | state: { 36 | isSubmitting, 37 | }, 38 | handleSubmit, 39 | }; 40 | } 41 | 42 | export default useSubmitModal; 43 | -------------------------------------------------------------------------------- /src/renderer/components/modals/preview-file/file-content/audio-content.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {toast} from "react-hot-toast"; 3 | 4 | import Duration, {convertDuration} from "@common/const/duration"; 5 | import {BackendMode} from "@common/qiniu" 6 | 7 | import {useI18n} from "@renderer/modules/i18n"; 8 | import {useAuth} from "@renderer/modules/auth"; 9 | import {getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; 10 | import {DomainAdapter, NON_OWNED_DOMAIN} from "@renderer/modules/qiniu-client-hooks"; 11 | 12 | import LoadingHolder from "@renderer/components/loading-holder"; 13 | 14 | interface AudioContentProps { 15 | regionId: string, 16 | bucketName: string, 17 | filePath: string, 18 | domain: DomainAdapter, 19 | } 20 | 21 | const AudioContent: React.FC = ({ 22 | regionId, 23 | bucketName, 24 | filePath, 25 | domain, 26 | }) => { 27 | const {translate} = useI18n(); 28 | const {currentUser} = useAuth(); 29 | 30 | const [audioSrc, setAudioSrc] = useState(); 31 | 32 | useEffect(() => { 33 | if (!currentUser || !filePath) { 34 | return; 35 | } 36 | 37 | const opt = { 38 | id: currentUser.accessKey, 39 | secret: currentUser.accessSecret, 40 | endpointType: currentUser.endpointType, 41 | preferS3Adapter: domain.apiScope === BackendMode.S3, 42 | }; 43 | 44 | const apiDomain = domain.name === NON_OWNED_DOMAIN.name 45 | ? undefined 46 | : domain; 47 | 48 | const style = getStyleForSignature({ 49 | domain: apiDomain, 50 | preferBackendMode: domain.apiScope === BackendMode.S3 ? BackendMode.S3 : BackendMode.Kodo, 51 | currentEndpointType: currentUser.endpointType, 52 | }); 53 | 54 | signatureUrl( 55 | regionId, 56 | bucketName, 57 | filePath, 58 | apiDomain, 59 | convertDuration(12 * Duration.Hour, Duration.Second), 60 | style, 61 | opt, 62 | ) 63 | .then(fileUrl => { 64 | setAudioSrc(fileUrl.toString()); 65 | }) 66 | .catch(() => { 67 | toast.error(translate("modals.preview.error.failedGenerateLink")); 68 | }); 69 | return () => { 70 | setAudioSrc(undefined); 71 | }; 72 | }, [filePath]); 73 | 74 | return ( 75 |
    76 | { 77 | !audioSrc 78 | ? 79 | :
    82 | ) 83 | }; 84 | 85 | export default AudioContent; 86 | -------------------------------------------------------------------------------- /src/renderer/components/modals/preview-file/file-content/others-content.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Button} from "react-bootstrap"; 3 | import {useI18n} from "@renderer/modules/i18n"; 4 | 5 | import {FileItem} from "@renderer/modules/qiniu-client"; 6 | 7 | interface OthersContentProps { 8 | onOpenAs: (t: FileItem.FileExtensionType) => void, 9 | } 10 | 11 | const OthersContent: React.FC = ({ 12 | onOpenAs, 13 | }) => { 14 | const {translate} = useI18n(); 15 | 16 | return ( 17 |
    18 | {translate("modals.preview.content.others.description")} 19 | 26 |
    27 | ); 28 | }; 29 | 30 | export default OthersContent; 31 | -------------------------------------------------------------------------------- /src/renderer/components/modals/preview-file/file-operation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {PropsWithChildren} from "react"; 2 | 3 | import StorageClass from "@common/models/storage-class"; 4 | 5 | import {FileItem} from "@renderer/modules/qiniu-client"; 6 | import {DomainAdapter} from "@renderer/modules/qiniu-client-hooks"; 7 | 8 | import {OperationDoneRecallFn} from "../../file/types"; 9 | import ChangeStorageClass from "./change-storage-class"; 10 | import GenerateLink from "./generate-link"; 11 | 12 | export enum FileOperationType { 13 | None = "none", 14 | GenerateLink = "generateLink", 15 | ChangeStorageClass = "changeStorageClass", 16 | } 17 | 18 | interface FileOperationProps { 19 | fileOperationType: FileOperationType, 20 | regionId: string, 21 | bucketName: string, 22 | basePath: string, 23 | fileItem: FileItem.File, 24 | canS3Domain: boolean, 25 | defaultDomain: DomainAdapter | undefined, 26 | storageClasses: StorageClass[], 27 | operationPortal: React.FC, 28 | onHideOperation: () => void, 29 | onOperationDone: OperationDoneRecallFn, 30 | } 31 | 32 | const FileOperation: React.FC = ({ 33 | fileOperationType, 34 | regionId, 35 | bucketName, 36 | basePath, 37 | fileItem, 38 | canS3Domain, 39 | defaultDomain, 40 | storageClasses, 41 | operationPortal, 42 | onHideOperation, 43 | onOperationDone, 44 | }) => { 45 | switch (fileOperationType) { 46 | case FileOperationType.GenerateLink: 47 | return ( 48 | 55 | ); 56 | case FileOperationType.ChangeStorageClass: 57 | return ( 58 | { 66 | onHideOperation(); 67 | onOperationDone(...args); 68 | }} 69 | /> 70 | ); 71 | } 72 | return null; 73 | }; 74 | 75 | export default FileOperation 76 | -------------------------------------------------------------------------------- /src/renderer/components/modals/preview-file/precheck/file-empty-name.tsx: -------------------------------------------------------------------------------- 1 | import React, {PropsWithChildren} from "react"; 2 | 3 | import {BackendMode} from "@common/qiniu"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | 7 | 8 | interface FileEmptyNameProps { 9 | fileName: string, 10 | backendMode: BackendMode, 11 | } 12 | 13 | const FileEmptyName: React.FC> = ({ 14 | fileName, 15 | backendMode, 16 | children, 17 | }) => { 18 | const {translate} = useI18n(); 19 | 20 | if (backendMode === BackendMode.S3 && !fileName) { 21 | return ( 22 |
    23 | {translate("modals.preview.error.emptyFileNameByS3Hint")} 24 |
    25 | ); 26 | } 27 | 28 | return ( 29 | <> 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | export default FileEmptyName; 36 | -------------------------------------------------------------------------------- /src/renderer/components/modals/preview-file/precheck/file-too-large.tsx: -------------------------------------------------------------------------------- 1 | import React, {PropsWithChildren, useEffect, useState} from "react"; 2 | import {Button} from "react-bootstrap"; 3 | 4 | import ByteSize, {byteSizeFormat} from "@common/const/byte-size"; 5 | import {Translate, useI18n} from "@renderer/modules/i18n"; 6 | 7 | interface FileTooLargeProps { 8 | fileSize: number, 9 | canForcePreview?: boolean, // default true 10 | maxPreviewSize?: number, // Bytes, default 5MB 11 | } 12 | 13 | const MAX_PREVIEW_SIZE = 5 * ByteSize.MB; 14 | 15 | const FileTooLarge: React.FC> = ({ 16 | children, 17 | fileSize, 18 | canForcePreview = true, 19 | maxPreviewSize = MAX_PREVIEW_SIZE, 20 | }) => { 21 | const {translate} = useI18n(); 22 | 23 | const [isShowContent, setIsShowContent] = useState(fileSize <= maxPreviewSize); 24 | 25 | useEffect(() => { 26 | setIsShowContent(fileSize <= maxPreviewSize); 27 | }, [fileSize, maxPreviewSize]); 28 | 29 | const i18nContent = { 30 | maxPreviewSize: byteSizeFormat(maxPreviewSize), 31 | }; 32 | 33 | return ( 34 | <> 35 | { 36 | !isShowContent 37 | ?
    38 | { 39 | !canForcePreview 40 | ? {v}, 45 | }} 46 | /> 47 | : <> 48 | {v}, 53 | }} 54 | /> 55 | 61 | 62 | } 63 |
    64 | : children 65 | } 66 | 67 | ); 68 | }; 69 | 70 | export default FileTooLarge; 71 | -------------------------------------------------------------------------------- /src/renderer/components/tooltip-button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {Button, OverlayTrigger, Spinner, Tooltip} from "react-bootstrap"; 3 | import {Placement} from "react-bootstrap/types"; 4 | import {ButtonProps} from "react-bootstrap/Button"; 5 | 6 | let hideOthersTooltip = () => {}; 7 | 8 | interface TooltipButtonProps { 9 | iconClassName: string, 10 | tooltipPlacement?: Placement, 11 | tooltipContent: string, 12 | show?: boolean, 13 | loading?: boolean, 14 | } 15 | 16 | const TooltipButton: React.FC = (props) => { 17 | const { 18 | iconClassName, 19 | tooltipPlacement, 20 | tooltipContent, 21 | show, 22 | loading = false, 23 | ...buttonProps 24 | } = props; 25 | 26 | const [showTip, setShowTip] = useState(); 27 | 28 | useEffect(() => { 29 | handleToggleTooltip(show); 30 | }, [show]); 31 | 32 | const handleToggleTooltip = (nextShow?: boolean) => { 33 | if (buttonProps.disabled) { 34 | setShowTip(false); 35 | return; 36 | } 37 | 38 | // only keep one tooltip. 39 | // prevent tips overlap each other if them too close. 40 | if (nextShow) { 41 | hideOthersTooltip(); 42 | hideOthersTooltip = () => { setShowTip(false); }; 43 | } 44 | setShowTip(nextShow); 45 | } 46 | 47 | return ( 48 | 54 | {tooltipContent} 55 | 56 | } 57 | > 58 | 69 | 70 | ) 71 | } 72 | 73 | export default TooltipButton; 74 | -------------------------------------------------------------------------------- /src/renderer/components/tooltip-text/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {OverlayTrigger, Tooltip} from "react-bootstrap"; 3 | import {Placement} from "react-bootstrap/types"; 4 | 5 | interface TooltipTextProps { 6 | delay?: number 7 | | { 8 | show: number, 9 | hide: number, 10 | }, 11 | tooltipPlacement?: Placement, 12 | tooltipContent: React.ReactNode | string, 13 | disabled?: boolean 14 | children: React.ReactElement, 15 | } 16 | 17 | const TooltipText: React.FC = ({ 18 | delay, 19 | tooltipPlacement, 20 | tooltipContent, 21 | disabled = false, 22 | children, 23 | }) => { 24 | 25 | // fix always show tip when disabled false->true 26 | const showTip = disabled ? false : undefined; 27 | 28 | return ( 29 | 35 | {tooltipContent} 36 | 37 | } 38 | > 39 | {children} 40 | 41 | ) 42 | } 43 | 44 | export default TooltipText; 45 | -------------------------------------------------------------------------------- /src/renderer/components/top/index.tsx: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | 3 | import React from "react"; 4 | import {Container, Navbar} from "react-bootstrap"; 5 | import classNames from "classnames"; 6 | 7 | import {app} from "@common/const/app-config"; 8 | import {useI18n} from "@renderer/modules/i18n"; 9 | 10 | import {MenuItem} from "./menu-item"; 11 | import MenuContents from "./menu-contents"; 12 | import "./top.scss"; 13 | 14 | let brandClickCount = 0; 15 | 16 | const handleBrandClick = () => { 17 | if (brandClickCount === 10) { 18 | ipcRenderer.send("asynchronous", { 19 | key: "openDevTools", 20 | }); 21 | brandClickCount = 0; 22 | return; 23 | } 24 | brandClickCount += 1; 25 | } 26 | 27 | interface TopProps { 28 | onClickVersion?: (version: string) => void, 29 | defaultMenuItems: MenuItem[], 30 | singedInMenuItems: MenuItem[], 31 | } 32 | 33 | const Top: React.FC = ({ 34 | onClickVersion, 35 | defaultMenuItems, 36 | singedInMenuItems, 37 | }) => { 38 | const {translate} = useI18n(); 39 | 40 | return ( 41 | 42 | 43 | 44 | logo 51 | 55 | {translate("common.kodoBrowser")} 56 | 57 | onClickVersion?.(app.version)} 66 | > 67 | v{app.version} 68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | 79 | ) 80 | }; 81 | 82 | export default Top; 83 | -------------------------------------------------------------------------------- /src/renderer/components/top/menu-item.ts: -------------------------------------------------------------------------------- 1 | import {ReactNode} from "react"; 2 | 3 | export enum MenuItemType { 4 | Link = "link", 5 | Dropdown = "dropdown", 6 | // Costume = "costume", 7 | } 8 | 9 | interface LinkItem { 10 | id: string, 11 | type: MenuItemType.Link, 12 | className?: string, 13 | iconClassName?: string, 14 | text: string | ReactNode, 15 | active?: boolean, 16 | onClick?: (item: MenuItem) => void, 17 | } 18 | 19 | interface DropdownItem { 20 | id: string, 21 | type: MenuItemType.Dropdown, 22 | className?: string, 23 | text: string | ReactNode, 24 | items: LinkItem[], 25 | } 26 | 27 | export type MenuItem = LinkItem | DropdownItem; 28 | -------------------------------------------------------------------------------- /src/renderer/components/top/top.scss: -------------------------------------------------------------------------------- 1 | .navbar-brand { 2 | & .app-name { 3 | margin-left: .5rem; 4 | } 5 | 6 | & .app-version { 7 | --clickable-focus-shadow-rgb: 255, 160, 0; 8 | margin-left: .5em; 9 | font-size: small; 10 | color: #FF9900; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/components/transfer-panel/const.ts: -------------------------------------------------------------------------------- 1 | import Duration from "@common/const/duration"; 2 | 3 | export const JOB_NUMS_PER_QUERY = 100; 4 | export const QUERY_INTERVAL = Duration.Second; 5 | export const ITEM_HEIGHT = 50; 6 | export const LOAD_MORE_THRESHOLD = 100; 7 | 8 | -------------------------------------------------------------------------------- /src/renderer/const/acl.ts: -------------------------------------------------------------------------------- 1 | enum ACL { 2 | Inherit = "inherit", 3 | PublicReadWrite = "public-read-write", 4 | PublicRead = "public-read", 5 | Private = "private", 6 | } 7 | 8 | export default ACL; 9 | -------------------------------------------------------------------------------- /src/renderer/const/kodo-nav.ts: -------------------------------------------------------------------------------- 1 | export const ADDR_KODO_PROTOCOL = "kodo://" 2 | 3 | export enum Mode { 4 | LocalBuckets = "localBuckets", 5 | LocalFiles = "localFiles", 6 | ExternalPaths = "externalPaths", 7 | ExternalFiles = "externalFiles", 8 | } 9 | 10 | export function isModeLocal(mode: Mode) { 11 | return mode.startsWith("local"); 12 | } 13 | 14 | export function isModeExternal(mode: Mode) { 15 | return mode.startsWith("external") 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/const/patterns.ts: -------------------------------------------------------------------------------- 1 | export const Alphanumeric = /[A-Za-z0-9]/; 2 | 3 | export const Email = /^\w+([-.]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/; 4 | 5 | /* 6 | * not start with / 7 | * and 8 | * not end with / 9 | * and 10 | * no multiple / inside 11 | */ 12 | export const FileRename = /^[^\/]$|^[^\/]((?!\/\/).)*[^\/]$/; 13 | 14 | 15 | export const BucketName = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/ 16 | 17 | /* 18 | * not include / 19 | */ 20 | export const DirectoryName = /^[^\/]+$/ 21 | 22 | export const HttpUrl = /^https?:\/\// 23 | -------------------------------------------------------------------------------- /src/renderer/customize.ts: -------------------------------------------------------------------------------- 1 | export const disable = { 2 | createBucket: false, 3 | deleteBucket: false, 4 | nonOwnedDomain: false, 5 | }; 6 | 7 | export const upgrade = { 8 | // Release Notes 目录后缀,里面有 ${version}.md, 如 1.0.0.md 9 | release_notes_url: "https://kodo-toolbox.qiniu.com/kodobrowser/release-notes/", 10 | 11 | // 升级检测链接 12 | check_url: "https://kodo-toolbox.qiniu.com/kodobrowser/update.json", 13 | }; 14 | -------------------------------------------------------------------------------- /src/renderer/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Kodo Browser 8 | <% for (let css of htmlWebpackPlugin.files.css) { %> 9 | 10 | <% } %> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | <% for (let js of htmlWebpackPlugin.files.js) { %> 19 | 20 | <% } %> 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import "bootstrap-icons/font/bootstrap-icons.css"; 2 | import "font-awesome/css/font-awesome.css"; 3 | import "react-base-table/styles.css"; 4 | 5 | import "./styles/index.scss"; 6 | 7 | import React from "react"; 8 | import {createRoot} from "react-dom/client"; 9 | import {MemoryRouter as Router} from "react-router-dom"; 10 | 11 | // create a RoutePath.Root page and check auth state will be more beautiful 12 | import {getCurrentUser} from "./modules/auth"; 13 | import {getEndpointConfig} from "./modules/user-config-store"; 14 | 15 | import setupApp from "./setup-app"; 16 | import RoutePath from "./pages/route-path"; 17 | 18 | (async function () { 19 | await setupApp(); 20 | const {default: App} = await import("./app"); 21 | const container = document.createElement("div"); 22 | container.id = "kodo-browser-app"; 23 | container.className = "h-100"; 24 | document.body.prepend(container); 25 | const root = createRoot(container); 26 | const currentUser = getCurrentUser(); 27 | const isSignedIn = currentUser !== null; 28 | if (isSignedIn) { 29 | await getEndpointConfig(currentUser).loadFromPersistence(); 30 | } 31 | root.render( 32 | 33 | 34 | , 35 | ); 36 | })(); 37 | -------------------------------------------------------------------------------- /src/renderer/modules/auth/cipher.test.ts: -------------------------------------------------------------------------------- 1 | import * as Cipher from './cipher'; 2 | 3 | describe("cipher", () => { 4 | it("cipher const", () => { 5 | const actual = Cipher.cipher("test cipher"); 6 | expect(actual).toBe("d8734177789bda96d17e693599380615"); 7 | }); 8 | 9 | it("decipher const", () => { 10 | const actual = Cipher.decipher("d8734177789bda96d17e693599380615"); 11 | expect(actual).toBe("test cipher"); 12 | }) 13 | 14 | it("cipher and decipher", () => { 15 | const data = "hello kodo-browser"; 16 | const encode = Cipher.cipher(data); 17 | const decode = Cipher.decipher(encode); 18 | expect(decode).toBe(data); 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/renderer/modules/auth/cipher.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto" 2 | 3 | const ALGORITHM = "aes-256-cbc"; 4 | const KEY = "x82m#*lx8vv"; 5 | 6 | export function cipher( 7 | data: string, 8 | key: string = KEY, 9 | algorithm: string = ALGORITHM, 10 | ): string { 11 | let encrypted = ""; 12 | const cip = crypto.createCipher(algorithm, key); 13 | encrypted += cip.update(data, "utf8", "hex"); 14 | encrypted += cip.final("hex"); 15 | return encrypted; 16 | } 17 | 18 | export function decipher( 19 | encrypted: string, 20 | key: string = KEY, 21 | algorithm: string = ALGORITHM, 22 | ):string { 23 | let decrypted = ""; 24 | const decipher = crypto.createDecipher(algorithm, key); 25 | decrypted += decipher.update(encrypted, "hex", "utf8"); 26 | decrypted += decipher.final("utf8"); 27 | return decrypted; 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./functions"; 3 | export * from "./react-context"; 4 | -------------------------------------------------------------------------------- /src/renderer/modules/auth/types.ts: -------------------------------------------------------------------------------- 1 | export enum EndpointType { 2 | Public = "public", 3 | Private = "private", 4 | ShareSession = "shareSession", 5 | } 6 | 7 | export enum AkSpecialType { 8 | IAM = "IAM", 9 | STS = "STS", 10 | } 11 | 12 | export interface AkItem { 13 | endpointType: EndpointType, 14 | accessKey: string, 15 | accessSecret: string, 16 | specialType?: AkSpecialType, 17 | description?: string, 18 | } 19 | 20 | export interface ShareSession { 21 | sessionToken: string, 22 | bucketName: string, 23 | bucketId: string, 24 | regionS3Id: string, 25 | endpoint: string, 26 | prefix: string, 27 | permission: 'READONLY' | 'READWRITE', 28 | expires: string, 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/code-mirror-container.scss: -------------------------------------------------------------------------------- 1 | .code-mirror-container { 2 | width: 100%; 3 | height: 100%; 4 | 5 | & .CodeMirror, .CodeMirror-merge { 6 | height: 100%; 7 | } 8 | 9 | & .CodeMirror-merge-pane { 10 | height: 100%; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/compatible.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror' 2 | import 'codemirror/lib/codemirror.css' 3 | 4 | import 'codemirror/addon/merge/merge' 5 | import 'codemirror/addon/merge/merge.css' 6 | import 'codemirror/mode/meta' 7 | 8 | export default CodeMirror 9 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/diff-view.tsx: -------------------------------------------------------------------------------- 1 | import React, {MutableRefObject, useEffect} from "react"; 2 | import {MergeView} from "codemirror/addon/merge/merge"; 3 | 4 | import useMergeView from "./hooks/use-merge-view"; 5 | 6 | type DiffViewProps = { 7 | dirtiedValue: string, 8 | originalValue: string, 9 | editorRef?: MutableRefObject 10 | // extensions: Extension[]; 11 | }; 12 | 13 | const DiffView: React.FC = ({ 14 | dirtiedValue, 15 | originalValue, 16 | editorRef, 17 | }) => { 18 | const {ref: editorContainerRef, editor} = useMergeView({ 19 | value: dirtiedValue, 20 | origLeft: originalValue, 21 | lineNumbers: true, 22 | lineWrapping: false, 23 | showDifferences: true, 24 | connect: "align", 25 | collapseIdentical: true, 26 | // readonly 27 | allowEditingOriginals: false, 28 | revertButtons: false, 29 | }); 30 | 31 | // update right 32 | useEffect(() => { 33 | const rightEditor = editor?.rightOriginal(); 34 | if (!editor || !rightEditor) { 35 | return; 36 | } 37 | rightEditor.setValue(dirtiedValue); 38 | editor.setShowDifferences(true); 39 | }, [dirtiedValue]); 40 | 41 | // update left 42 | useEffect(() => { 43 | const leftEditor = editor?.leftOriginal(); 44 | if (!editor || !leftEditor) { 45 | return; 46 | } 47 | leftEditor.setValue(dirtiedValue); 48 | editor.setShowDifferences(true); 49 | }, [originalValue]); 50 | 51 | useEffect(() => { 52 | if (!editorRef) { 53 | return; 54 | } 55 | editorRef.current = editor; 56 | }, [editor]); 57 | 58 | return ( 59 |
    63 | ); 64 | }; 65 | 66 | export default DiffView; 67 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/editor-view.tsx: -------------------------------------------------------------------------------- 1 | import React, {MutableRefObject, useEffect} from "react"; 2 | import {Editor} from "codemirror"; 3 | 4 | import useEditorView from "./hooks/use-editor-view"; 5 | import "./code-mirror-container.scss"; 6 | 7 | type EditorViewProps = { 8 | defaultValue: string, 9 | readOnly?: boolean, 10 | editorRef?: MutableRefObject 11 | // extensions: Extension[]; 12 | }; 13 | 14 | const EditorView: React.FC = ({ 15 | defaultValue, 16 | readOnly, 17 | editorRef, 18 | }) => { 19 | const {ref: editorContainerRef, editor} = useEditorView({ 20 | value: defaultValue, 21 | lineNumbers: true, 22 | lineWrapping: false, 23 | readOnly, 24 | }); 25 | 26 | useEffect(() => { 27 | if (!editor) { 28 | return; 29 | } 30 | editor.setValue(defaultValue); 31 | }, [defaultValue]); 32 | 33 | useEffect(() => { 34 | if (!editorRef) { 35 | return; 36 | } 37 | editorRef.current = editor; 38 | }, [editor]); 39 | 40 | return ( 41 |
    45 | ); 46 | }; 47 | 48 | export default EditorView; 49 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/hooks/use-editor-view.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react"; 2 | import {Editor, EditorConfiguration} from "codemirror"; 3 | 4 | import CodeMirror from "../compatible"; 5 | 6 | const useEditorView = (editorConfiguration: EditorConfiguration) => { 7 | const [element, setElement] = useState(); 8 | const [editor, setEditor] = useState(); 9 | 10 | const ref = useCallback((node: HTMLElement | null) => { 11 | if (!node) { 12 | return; 13 | } 14 | 15 | setElement(node); 16 | }, []); 17 | 18 | useEffect(() => { 19 | if (!element) { 20 | return; 21 | } 22 | 23 | setEditor(CodeMirror( 24 | element, 25 | editorConfiguration, 26 | )); 27 | 28 | return () => { 29 | setEditor(undefined); 30 | element.remove(); 31 | }; 32 | }, [element]); 33 | 34 | return { 35 | ref, 36 | editor, 37 | }; 38 | } 39 | 40 | export default useEditorView; 41 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/hooks/use-merge-view.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react"; 2 | import {MergeView, MergeViewConfiguration} from "codemirror/addon/merge/merge"; 3 | 4 | import CodeMirror from "../compatible"; 5 | 6 | const useMergeView = (mergeViewConfiguration: MergeViewConfiguration) => { 7 | const [element, setElement] = useState(); 8 | const [editor, setEditor] = useState(); 9 | 10 | const ref = useCallback((node: HTMLElement | null) => { 11 | if (!node) { 12 | return; 13 | } 14 | 15 | setElement(node); 16 | }, []); 17 | 18 | useEffect(() => { 19 | if (!element) { 20 | return; 21 | } 22 | 23 | setEditor(CodeMirror.MergeView( 24 | element, 25 | mergeViewConfiguration, 26 | )); 27 | 28 | return () => { 29 | setEditor(undefined); 30 | element.remove(); 31 | }; 32 | }, [element]); 33 | 34 | return { 35 | ref, 36 | editor, 37 | }; 38 | } 39 | 40 | export default useMergeView; 41 | -------------------------------------------------------------------------------- /src/renderer/modules/codemirror/index.tsx: -------------------------------------------------------------------------------- 1 | import "./code-mirror-container.scss"; 2 | 3 | export {default as EditorView} from "./editor-view"; 4 | export {default as DiffView} from "./diff-view"; 5 | -------------------------------------------------------------------------------- /src/renderer/modules/default-dict/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Change some default values which are hard coded in project 3 | * */ 4 | interface Dict { 5 | LOGIN_ENDPOINT_TYPE?: string, 6 | PRIVATE_ENDPOINT?: { 7 | ucUrl: string, 8 | regions: { 9 | identifier: string, 10 | label: string, 11 | endpoint: string, 12 | }[], 13 | }, 14 | BASE_SHARE_URL?: string, 15 | DISABLE_NON_OWNED_DOMAIN?: boolean, 16 | PREFERENCE_VALIDATORS?: { 17 | maxMultipartUploadPartSize?: number, 18 | maxMultipartUploadConcurrency?: number, 19 | maxUploadJobConcurrency?: number, 20 | maxDownloadJobConcurrency?: number, 21 | }, 22 | MAX_SHARE_DIRECTORY_EXPIRE_AFTER_SECONDS?: number, 23 | } 24 | 25 | const dict: Dict = {}; 26 | 27 | export function get(key: T): Dict[T] { 28 | return dict[key]; 29 | } 30 | 31 | export function set(key: T, val: Dict[T]) { 32 | dict[key] = val; 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/modules/electron-ipc-manages/ipc-download-manager.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | 3 | import {DownloadActionFns} from "@common/ipc-actions/download"; 4 | 5 | const ipcDownloadManager = new DownloadActionFns(ipcRenderer, "DownloaderManager"); 6 | 7 | export default ipcDownloadManager; 8 | -------------------------------------------------------------------------------- /src/renderer/modules/electron-ipc-manages/ipc-upload-manager.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | 3 | import {UploadActionFns} from "@common/ipc-actions/upload"; 4 | 5 | const ipcUploadManager = new UploadActionFns(ipcRenderer, "UploaderManager"); 6 | 7 | export default ipcUploadManager; 8 | -------------------------------------------------------------------------------- /src/renderer/modules/electron-ipc-manages/use-ipc-deep-links.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer, IpcRendererEvent} from "electron"; 2 | 3 | import { 4 | DeepLinkMessage, 5 | DeepLinkAction, 6 | DeepLinkSignInWithShareLinkMessage, 7 | DeepLinkSignInWithShareSessionMessage, 8 | DeepLinkActionFns 9 | } from "@common/ipc-actions/deep-link"; 10 | import {useEffect, useRef} from "react"; 11 | 12 | // TODO: refactor other managers. make them hooks style. 13 | 14 | interface useIpcDeepLinkProps { 15 | onSignInDataInvalid: () => void, 16 | onSignInWithShareLink: (data: DeepLinkSignInWithShareLinkMessage["data"]) => void, 17 | onSignInWithShareSession: (data: DeepLinkSignInWithShareSessionMessage["data"]) => void, 18 | } 19 | 20 | let DEEP_LINK_CHANNEL = "DeepLink"; 21 | let rendererReady = 0; 22 | 23 | function useIpcDeepLink({ 24 | onSignInDataInvalid, 25 | onSignInWithShareLink, 26 | onSignInWithShareSession, 27 | }: useIpcDeepLinkProps) { 28 | const uploadReplyHandler = (_event: IpcRendererEvent, message: DeepLinkMessage) => { 29 | switch (message.action) { 30 | case DeepLinkAction.SignInDataInvalid: 31 | onSignInDataInvalid(); 32 | break; 33 | case DeepLinkAction.SignInWithShareLink: 34 | onSignInWithShareLink(message.data); 35 | break; 36 | case DeepLinkAction.SignInWithShareSession: 37 | onSignInWithShareSession(message.data); 38 | break; 39 | } 40 | }; 41 | 42 | const uploadReplyHandlerRef = useRef(uploadReplyHandler); 43 | uploadReplyHandlerRef.current = uploadReplyHandler; 44 | 45 | useEffect(() => { 46 | const handler = (event: Electron.IpcRendererEvent, msg: any) => { 47 | uploadReplyHandlerRef.current(event, msg); 48 | } 49 | ipcRenderer.on(DEEP_LINK_CHANNEL, handler); 50 | if (!rendererReady) { 51 | const deepLinkActionFns = new DeepLinkActionFns(ipcRenderer, DEEP_LINK_CHANNEL); 52 | deepLinkActionFns.rendererReady(); 53 | } 54 | rendererReady += 1; 55 | 56 | return () => { 57 | rendererReady -= 1; 58 | if (!rendererReady) { 59 | const deepLinkActionFns = new DeepLinkActionFns(ipcRenderer, DEEP_LINK_CHANNEL); 60 | deepLinkActionFns.rendererClose(); 61 | } 62 | ipcRenderer.off(DEEP_LINK_CHANNEL, handler); 63 | } 64 | }, []); 65 | } 66 | 67 | export default useIpcDeepLink; 68 | -------------------------------------------------------------------------------- /src/renderer/modules/external-store/index.ts: -------------------------------------------------------------------------------- 1 | type Listener = () => void; 2 | 3 | export default class ExternalStore { 4 | protected data: T; 5 | protected listeners: Set; 6 | 7 | constructor(data: T) { 8 | this.data = data; 9 | this.listeners = new Set(); 10 | this.subscribe = this.subscribe.bind(this); 11 | this.getSnapshot = this.getSnapshot.bind(this); 12 | this.dispatch = this.dispatch.bind(this); 13 | } 14 | 15 | subscribe(l: Listener) { 16 | this.listeners.add(l); 17 | return () => this.listeners.delete(l); 18 | } 19 | 20 | getSnapshot() { 21 | return this.data; 22 | } 23 | 24 | dispatch(data: Partial | ((prevData: T) => Partial)) { 25 | this.data = { 26 | ...this.data, 27 | ...data, 28 | }; 29 | this.listeners.forEach(l => l()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/modules/file-operation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, PropsWithChildren, useContext, useState} from "react"; 2 | 3 | import {BackendMode} from "@common/qiniu"; 4 | 5 | import * as FileItem from "@renderer/modules/qiniu-client/file-item"; 6 | 7 | export enum FilesOperationType { 8 | Copy = "copy", 9 | Move = "move", 10 | } 11 | 12 | export interface FileOperation { 13 | action: FilesOperationType, 14 | bucketName: string, 15 | regionId: string, 16 | files: FileItem.Item[], 17 | basePath: string, 18 | } 19 | 20 | const FileOperationContext = createContext<{ 21 | bucketPreferBackendMode?: BackendMode, 22 | bucketGrantedPermission?: 'readonly' | 'readwrite', 23 | fileOperation: FileOperation | null, 24 | setFileOperation: (operation: FileOperation | null) => void, 25 | }>({ 26 | bucketPreferBackendMode: undefined, 27 | bucketGrantedPermission: undefined, 28 | fileOperation: null, 29 | setFileOperation: () => {}, 30 | }); 31 | 32 | export const Provider: React.FC> = ({ 37 | bucketPreferBackendMode, 38 | bucketGrantedPermission, 39 | defaultFileOperation = null, 40 | children, 41 | }) => { 42 | const [fileOperation, setFileOperation] = useState(defaultFileOperation); 43 | 44 | return ( 45 | 51 | {children} 52 | 53 | ); 54 | }; 55 | 56 | export function useFileOperation() { 57 | return useContext(FileOperationContext); 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-is-overflow.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react"; 2 | 3 | const useIsOverflow = () => { 4 | const [element, setElement] = useState(); 5 | const [isOverflow, setIsOverflow] = useState(false); 6 | 7 | const ref = useCallback((node: HTMLElement | null) => { 8 | if (!node) { 9 | return; 10 | } 11 | 12 | setElement(node); 13 | }, []); 14 | 15 | useEffect(() => { 16 | if (!element) { 17 | return; 18 | } 19 | 20 | const checkOverflow = () => { 21 | if ( 22 | element.clientWidth < element.scrollWidth || 23 | element.clientHeight < element.scrollHeight 24 | ) { 25 | setIsOverflow(true); 26 | } else { 27 | setIsOverflow(false); 28 | } 29 | }; 30 | 31 | checkOverflow(); 32 | 33 | const observer = new ResizeObserver(checkOverflow); 34 | observer.observe(element); 35 | 36 | return () => { 37 | observer.disconnect(); 38 | }; 39 | }, [element]); 40 | 41 | return { 42 | ref, 43 | isOverflow, 44 | }; 45 | }; 46 | 47 | export default useIsOverflow; 48 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-mount.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | 3 | const useMount = (fn: () => void) => { 4 | useEffect(() => { 5 | fn(); 6 | }, []); 7 | }; 8 | 9 | export default useMount; 10 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-portal.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useState} from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | const usePortal = () => { 5 | const [element, setElement] = useState(); 6 | 7 | const ref = useCallback((node: HTMLElement | null) => { 8 | if (!node) { 9 | return; 10 | } 11 | 12 | setElement(node); 13 | }, []); 14 | 15 | const portal = useCallback>(({children}) => { 16 | if (!element) { 17 | return null; 18 | } 19 | 20 | return ReactDOM.createPortal( 21 | children, 22 | element, 23 | ); 24 | }, [element]); 25 | 26 | return { 27 | ref, 28 | portal, 29 | }; 30 | }; 31 | 32 | export default usePortal; 33 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-raf-state.ts: -------------------------------------------------------------------------------- 1 | import {Dispatch, SetStateAction, useCallback, useRef, useState} from "react"; 2 | 3 | import useUnmount from "./use-unmount"; 4 | 5 | type MayUndefined = T | undefined; 6 | 7 | /* 8 | * React lifecycle hook that calls a function when the component will unmount. Use useLifecycles if you need both a mount and unmount function. 9 | * 10 | * Source code modified from [react-use/useRafState](https://github.com/streamich/react-use/blob/cb5dca6ab12ba37ee80ee9d9f4b14f973e5b0d49/src/useRafState.ts). 11 | * Licensed under [The Unlicense](https://unlicense.org). 12 | **/ 13 | function useRafState(initialState: S | (() => S)): [S, Dispatch>] 14 | function useRafState(): [MayUndefined, Dispatch>>] 15 | 16 | function useRafState(initialState?: S | (() => S)): [(MayUndefined), Dispatch)>>] { 17 | const frame = useRef(0); 18 | const [state, setState] = useState>(initialState); 19 | 20 | const setRafState = useCallback(( 21 | value: MayUndefined | ((prevState: MayUndefined) => MayUndefined) 22 | ) => { 23 | cancelAnimationFrame(frame.current); 24 | 25 | frame.current = requestAnimationFrame(() => { 26 | setState(value); 27 | }); 28 | }, []); 29 | 30 | useUnmount(() => { 31 | cancelAnimationFrame(frame.current); 32 | }); 33 | 34 | return [state, setRafState]; 35 | } 36 | 37 | export default useRafState; 38 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-scroll.ts: -------------------------------------------------------------------------------- 1 | import {RefObject, useEffect} from "react"; 2 | 3 | import useRafState from "./use-raf-state"; 4 | 5 | interface Position { 6 | left: number, 7 | top: number, 8 | isTouchEnd: boolean, 9 | } 10 | 11 | interface scrollConfig { 12 | touchEndThreshold: number, 13 | } 14 | 15 | const useScroll = (target: RefObject, scrollConfig?: scrollConfig): Position | undefined => { 16 | const [position, setPosition] = useRafState(); 17 | 18 | useEffect(() => { 19 | const handler = () => { 20 | if (target.current) { 21 | const { 22 | scrollLeft, 23 | scrollTop, 24 | scrollHeight, 25 | clientHeight, 26 | } = target.current; 27 | const isTouchEnd = 28 | Math.round(scrollTop) + clientHeight > scrollHeight - (scrollConfig?.touchEndThreshold ?? 10); 29 | setPosition({ 30 | left: scrollLeft, 31 | top: scrollTop, 32 | isTouchEnd: isTouchEnd, 33 | }); 34 | } 35 | }; 36 | 37 | if (target.current) { 38 | target.current.addEventListener('scroll', handler, { 39 | capture: false, 40 | passive: true, 41 | }); 42 | } 43 | 44 | return () => { 45 | if (target.current) { 46 | target.current.removeEventListener('scroll', handler); 47 | } 48 | }; 49 | }, [target]); 50 | 51 | return position; 52 | } 53 | 54 | export default useScroll; 55 | -------------------------------------------------------------------------------- /src/renderer/modules/hooks/use-unmount.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from 'react'; 2 | 3 | 4 | /* 5 | * React lifecycle hook that calls a function when the component will unmount. Use useLifecycles if you need both a mount and unmount function. 6 | * 7 | * Source code modified from [react-use/useUnmount](https://github.com/streamich/react-use/blob/cb5dca6ab12ba37ee80ee9d9f4b14f973e5b0d49/src/useUnmount.ts). 8 | * licensed under [The Unlicense](https://unlicense.org). 9 | **/ 10 | const useUnmount = (fn: () => any): void => { 11 | const fnRef = useRef(fn); 12 | 13 | // update the ref each render so if it changes the newest callback will be invoked 14 | fnRef.current = fn; 15 | 16 | useEffect(() => () => fnRef.current(), []); 17 | }; 18 | 19 | export default useUnmount; 20 | -------------------------------------------------------------------------------- /src/renderer/modules/i18n/extra/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./job-status"; 2 | -------------------------------------------------------------------------------- /src/renderer/modules/i18n/extra/job-status.ts: -------------------------------------------------------------------------------- 1 | import {Status} from "@common/models/job/types"; 2 | type StatusI18nKey = "transfer.jobItem.status.finished" 3 | | "transfer.jobItem.status.failed" 4 | | "transfer.jobItem.status.stopped" 5 | | "transfer.jobItem.status.waiting" 6 | | "transfer.jobItem.status.running" 7 | | "transfer.jobItem.status.duplicated" 8 | | "transfer.jobItem.status.verifying" 9 | 10 | export const Status2I18nKey: Record = { 11 | [Status.Finished]: "transfer.jobItem.status.finished", 12 | [Status.Failed]: "transfer.jobItem.status.failed", 13 | [Status.Stopped]: "transfer.jobItem.status.stopped", 14 | [Status.Waiting]: "transfer.jobItem.status.waiting", 15 | [Status.Running]: "transfer.jobItem.status.running", 16 | [Status.Duplicated]: "transfer.jobItem.status.duplicated", 17 | [Status.Verifying]: "transfer.jobItem.status.verifying", 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/modules/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core"; 2 | export * from "./react-context"; 3 | export * from "./react-component"; 4 | -------------------------------------------------------------------------------- /src/renderer/modules/i18n/react-component.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {PropsPath} from "@common/utility-types"; 4 | 5 | import Dictionary from "./lang/dict"; 6 | import {splitVariables} from "./core"; 7 | import {useI18n} from "./react-context"; 8 | 9 | interface TranslateProps> { 10 | i18nKey: PropsPath, 11 | data: T, 12 | slots?: Record React.ReactNode> 13 | } 14 | 15 | export const Translate = >(props: TranslateProps): JSX.Element => { 16 | const { 17 | i18nKey, 18 | data, 19 | slots, 20 | } = props; 21 | 22 | const {translate} = useI18n(); 23 | 24 | return ( 25 | <> 26 | { 27 | splitVariables(translate(i18nKey)) 28 | .map(snippet => { 29 | if (snippet.isVar) { 30 | return ( 31 | 32 | { 33 | slots?.[snippet.value] 34 | ? slots[snippet.value](data[snippet.value]) 35 | : data[snippet.value] 36 | } 37 | 38 | ); 39 | } 40 | return snippet.value; 41 | }) 42 | } 43 | 44 | ) 45 | }; 46 | -------------------------------------------------------------------------------- /src/renderer/modules/i18n/react-context.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useState} from "react"; 2 | 3 | import {translate, getLang, LangName, setLang} from "./core"; 4 | 5 | // react hooks 6 | const I18nContext = createContext<{ 7 | availableLanguages: LangName[], 8 | currentLanguage: LangName, 9 | translate: typeof translate, 10 | setLanguage: (lang: LangName) => Promise, 11 | }>({ 12 | availableLanguages: Object.values(LangName), 13 | currentLanguage: LangName.ZH_CN, 14 | translate: translate, 15 | setLanguage: async (lang) => { 16 | await setLang(lang); 17 | }, 18 | }); 19 | 20 | export const Provider: React.FC<{ 21 | children: React.ReactNode, 22 | }> = ({children}) => { 23 | const [currentLang, setCurrentLang] = useState(getLang); 24 | 25 | const setLanguage = async (lang: LangName): Promise => { 26 | await setLang(lang); 27 | setCurrentLang(lang); 28 | } 29 | 30 | return ( 31 | 37 | {children} 38 | 39 | ) 40 | } 41 | 42 | export function useI18n() { 43 | return useContext(I18nContext); 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/modules/kodo-address/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./navigator"; 3 | export * from "./react-context"; 4 | -------------------------------------------------------------------------------- /src/renderer/modules/kodo-address/types.ts: -------------------------------------------------------------------------------- 1 | export interface KodoAddress { 2 | protocol: string, 3 | path: string, 4 | } 5 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/chore-configs.ts: -------------------------------------------------------------------------------- 1 | import * as DefaultDict from "@renderer/modules/default-dict"; 2 | 3 | import {LaunchConfigPlugin, LaunchConfigSetupOptions} from "./types"; 4 | 5 | class ChoreConfigs implements LaunchConfigPlugin { 6 | setup(options: LaunchConfigSetupOptions) { 7 | if (options.launchConfig.preferredEndpointType) { 8 | DefaultDict.set("LOGIN_ENDPOINT_TYPE", options.launchConfig.preferredEndpointType); 9 | } 10 | if (options.launchConfig.baseShareUrl) { 11 | DefaultDict.set("BASE_SHARE_URL", options.launchConfig.baseShareUrl); 12 | } 13 | if (options.launchConfig.maxShareDirectoryExpireAfterSeconds) { 14 | DefaultDict.set("MAX_SHARE_DIRECTORY_EXPIRE_AFTER_SECONDS", options.launchConfig.maxShareDirectoryExpireAfterSeconds); 15 | } 16 | } 17 | } 18 | 19 | export default ChoreConfigs; 20 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/default-private-endpoint.ts: -------------------------------------------------------------------------------- 1 | import * as DefaultDict from "@renderer/modules/default-dict"; 2 | import {LaunchConfigSetupOptions, LaunchConfigPlugin} from "./types"; 3 | 4 | class DefaultPrivateEndpoint implements LaunchConfigPlugin { 5 | setup({launchConfig}: LaunchConfigSetupOptions) { 6 | if (!launchConfig.defaultPrivateEndpointConfig) { 7 | return; 8 | } 9 | const defaultPrivateEndpointConfig = launchConfig.defaultPrivateEndpointConfig; 10 | DefaultDict.set("PRIVATE_ENDPOINT", { 11 | ucUrl: defaultPrivateEndpointConfig.ucUrl, 12 | regions: defaultPrivateEndpointConfig.regions.map(r => ({ 13 | identifier: r.id, 14 | label: r.label ?? "", 15 | endpoint: r.endpoint, 16 | })), 17 | }); 18 | } 19 | } 20 | 21 | export default DefaultPrivateEndpoint; 22 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/disable-functions.ts: -------------------------------------------------------------------------------- 1 | import * as DefaultDict from "@renderer/modules/default-dict"; 2 | 3 | import {LaunchConfigPlugin, LaunchConfigSetupOptions} from "./types"; 4 | 5 | class DisableFunctions implements LaunchConfigPlugin { 6 | setup(options: LaunchConfigSetupOptions) { 7 | if (!options.launchConfig.disable) { 8 | return; 9 | } 10 | if (options.launchConfig.disable.nonOwnedDomain !== undefined) { 11 | DefaultDict.set("DISABLE_NON_OWNED_DOMAIN", options.launchConfig.disable.nonOwnedDomain); 12 | } 13 | } 14 | } 15 | 16 | export default DisableFunctions; 17 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/index.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {app} from "@electron/remote"; 3 | 4 | import {LocalFile, serializer} from "@renderer/modules/persistence"; 5 | 6 | import {LaunchConfigPlugin} from "./types"; 7 | import DefaultPrivateEndpoint from "./default-private-endpoint"; 8 | import ChoreConfigs from "./chore-configs"; 9 | import DisableFunctions from "./disable-functions"; 10 | import PreferenceValidator from "./preference-validator"; 11 | 12 | class LaunchConfig { 13 | static basePath = path.dirname(app.getPath("exe")); 14 | static filePath = "launchConfig.json"; 15 | 16 | setup() { 17 | const localFile = new LocalFile({ 18 | workingDirectory: LaunchConfig.basePath, 19 | filePath: LaunchConfig.filePath, 20 | serializer: new serializer.JSONSerializer(), 21 | }); 22 | 23 | localFile.load().then(data => { 24 | if (!data) { 25 | return; 26 | } 27 | const plugins: LaunchConfigPlugin[] = [ 28 | new ChoreConfigs(), 29 | new DefaultPrivateEndpoint(), 30 | new DisableFunctions(), 31 | new PreferenceValidator(), 32 | ]; 33 | plugins.forEach(plugin => { 34 | plugin.setup({ 35 | launchConfig: data, 36 | }); 37 | });}) 38 | } 39 | } 40 | 41 | export default LaunchConfig; 42 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/preference-validator.ts: -------------------------------------------------------------------------------- 1 | import * as DefaultDict from "@renderer/modules/default-dict"; 2 | 3 | import {LaunchConfigPlugin, LaunchConfigSetupOptions} from "./types"; 4 | 5 | class PreferenceValidator implements LaunchConfigPlugin { 6 | setup(options: LaunchConfigSetupOptions) { 7 | if (!options.launchConfig.preferenceValidators) { 8 | return; 9 | } 10 | const defaultPreferenceValidators = options.launchConfig.preferenceValidators; 11 | DefaultDict.set("PREFERENCE_VALIDATORS", defaultPreferenceValidators); 12 | } 13 | } 14 | 15 | export default PreferenceValidator; 16 | -------------------------------------------------------------------------------- /src/renderer/modules/launch-config/types.ts: -------------------------------------------------------------------------------- 1 | export interface LaunchConfigSetupOptions { 2 | launchConfig: LaunchConfig, 3 | } 4 | 5 | export interface LaunchConfig { 6 | preferredEndpointType?: string, 7 | defaultPrivateEndpointConfig?: DefaultPrivateEndpointConfig, 8 | preferenceValidators?: PreferenceValidators, 9 | baseShareUrl?: string, 10 | maxShareDirectoryExpireAfterSeconds?: number, 11 | disable?: DisableFunctions, 12 | } 13 | 14 | export interface DefaultPrivateEndpointConfig { 15 | ucUrl: string, 16 | regions: { 17 | id: string, 18 | label?: string, 19 | endpoint: string, 20 | }[], 21 | } 22 | 23 | export interface DisableFunctions { 24 | nonOwnedDomain?: boolean, 25 | } 26 | 27 | export interface PreferenceValidators { 28 | maxMultipartUploadPartSize?: number, 29 | maxMultipartUploadConcurrency?: number, 30 | maxUploadJobConcurrency?: number, 31 | maxDownloadJobConcurrency?: number, 32 | } 33 | 34 | export interface LaunchConfigPlugin { 35 | setup(options: LaunchConfigSetupOptions): void, 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/modules/local-logger/index.ts: -------------------------------------------------------------------------------- 1 | // this module could move to @common/local-logger 2 | export * from "./levels"; 3 | export * from "./loggers"; 4 | -------------------------------------------------------------------------------- /src/renderer/modules/local-logger/levels.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | All, 3 | Debug, 4 | Info, 5 | Warning, 6 | Error, 7 | No, 8 | } 9 | 10 | 11 | let currentLogLevel = LogLevel.Error; 12 | 13 | export function getLevel(): LogLevel { 14 | return currentLogLevel 15 | } 16 | 17 | /** 18 | * if not debugEnv, debug level will be info level. 19 | */ 20 | export function setLevel(level: LogLevel) { 21 | // ignore debugEnv type 22 | // @ts-ignore 23 | if (level === LogLevel.Debug && !(window?.debugEnv || global?.debugEvn)) { 24 | currentLogLevel = LogLevel.Info; 25 | return; 26 | } 27 | currentLogLevel = level 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/modules/local-logger/loggers.ts: -------------------------------------------------------------------------------- 1 | import {getLevel, LogLevel} from "./levels"; 2 | import parseErrorStack, {StackFrame} from "./parse-error-stack"; 3 | 4 | const IS_NATIVE_LOGGER = false; 5 | const SAVE_LOG_STACK = false; 6 | const MAX_LOG_STACK = 100; 7 | 8 | interface LogQueue { 9 | logArgs: any[], 10 | callStack: StackFrame[], 11 | } 12 | 13 | const logQueue: LogQueue[] = []; 14 | 15 | const debug = IS_NATIVE_LOGGER 16 | ? console.debug 17 | : (...args: any[]) => { 18 | if (getLevel() > LogLevel.Debug) { 19 | return; 20 | } 21 | console.debug(...args); 22 | saveLogs(args, new Error().stack); 23 | } 24 | 25 | const info = IS_NATIVE_LOGGER 26 | ? console.info 27 | : (function (...args: any[]) { 28 | if (getLevel() > LogLevel.Info) { 29 | return; 30 | } 31 | console.info(...args); 32 | saveLogs(args, new Error().stack); 33 | }).bind(console); 34 | 35 | const warn = IS_NATIVE_LOGGER 36 | ? console.warn 37 | : (...args: any[]) => { 38 | if (getLevel() > LogLevel.Warning) { 39 | return; 40 | } 41 | console.warn(...args); 42 | saveLogs(args, new Error().stack); 43 | } 44 | 45 | const error = IS_NATIVE_LOGGER 46 | ? console.error 47 | : (...args: any[]) => { 48 | if (getLevel() > LogLevel.Error) { 49 | return; 50 | } 51 | console.error(...args); 52 | saveLogs(args, new Error().stack); 53 | } 54 | 55 | export { 56 | debug, 57 | info, 58 | warn, 59 | error, 60 | }; 61 | 62 | function saveLogs(logArgs: any[], stackStr: string = "") { 63 | if (SAVE_LOG_STACK) { 64 | if (logQueue.length >= MAX_LOG_STACK) { 65 | logQueue.shift(); 66 | } 67 | logQueue.push({ 68 | logArgs: logArgs, 69 | callStack: parseErrorStack(stackStr), 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/modules/local-logger/parse-error-stack.ts: -------------------------------------------------------------------------------- 1 | export interface StackFrame { 2 | funcName: string, 3 | fileLink: string, 4 | } 5 | 6 | export default function (s: string = ""): StackFrame[] { 7 | const result: StackFrame[] = []; 8 | s.split("\n") 9 | .forEach((stackStr, i) => { 10 | if (i < 2) { 11 | return; 12 | } 13 | const stackRawList = stackStr.trim().split(" "); 14 | let funcName = ""; 15 | let fileLink = ""; 16 | if (stackRawList.length >= 3) { 17 | [, funcName, fileLink] = stackRawList; 18 | fileLink = fileLink.slice(1, -1); 19 | } else if (stackRawList.length >= 2) { 20 | [, fileLink] = stackRawList; 21 | } 22 | result.push({ 23 | funcName: funcName, 24 | fileLink: fileLink, 25 | }); 26 | }); 27 | return result; 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/modules/markdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | 3 | import {ConverterOptions} from "showdown"; 4 | 5 | import useMarkdown from "./use-markdown"; 6 | 7 | interface MarkdownViewProps { 8 | text: string | undefined, 9 | converterOptions?: ConverterOptions, 10 | } 11 | 12 | const MarkdownView: React.FC = ({ 13 | text = "", 14 | converterOptions, 15 | }) => { 16 | const {ref, setText} = useMarkdown(converterOptions); 17 | 18 | useEffect(() => { 19 | setText(text); 20 | }, [text]); 21 | 22 | return ( 23 |
    24 | ); 25 | }; 26 | 27 | export default MarkdownView; 28 | -------------------------------------------------------------------------------- /src/renderer/modules/markdown/use-markdown.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useMemo, useState} from "react"; 2 | import showdown, {ConverterOptions} from "showdown"; 3 | 4 | const useMarkdown = (converterOptions?: ConverterOptions) => { 5 | const [element, setElement] = useState(); 6 | const [text, setText] = useState(""); 7 | 8 | const ref = useCallback((node: HTMLElement | null) => { 9 | if (!node) { 10 | return; 11 | } 12 | 13 | setElement(node); 14 | }, []); 15 | 16 | const converter = useMemo(() => { 17 | return new showdown.Converter(converterOptions); 18 | }, [converterOptions]); 19 | 20 | useEffect(() => { 21 | if (!element) { 22 | return; 23 | } 24 | 25 | element.innerHTML = converter.makeHtml(text); 26 | 27 | }, [element, text]); 28 | 29 | return { 30 | ref, 31 | setText, 32 | }; 33 | }; 34 | 35 | export default useMarkdown; 36 | -------------------------------------------------------------------------------- /src/renderer/modules/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export * as serializer from "./serializer"; 2 | export {default as Persistence} from "./persistence"; 3 | export {default as BrowserStorage} from "./browser-storage"; 4 | export {default as LocalFile} from "./local-file"; 5 | -------------------------------------------------------------------------------- /src/renderer/modules/persistence/persistence.ts: -------------------------------------------------------------------------------- 1 | import {Serializer} from "./serializer"; 2 | 3 | interface ChangeData { 4 | value: T, 5 | } 6 | 7 | type Listener = { 8 | callback: (data: ChangeData) => void 9 | } 10 | 11 | export default abstract class Persistence { 12 | protected abstract serializer: Serializer; 13 | protected listeners = new Set>(); 14 | 15 | protected constructor() { 16 | this.triggerChange = this.triggerChange.bind(this); 17 | } 18 | 19 | protected abstract _save(value: string): Promise; 20 | protected abstract _load(): Promise; 21 | abstract clear(): Promise; 22 | abstract watch(): void; 23 | abstract unwatch(): void; 24 | 25 | async save(value: T): Promise { 26 | const encodedData = this.serializer.serialize(value); 27 | await this._save(encodedData); 28 | } 29 | 30 | async load(): Promise { 31 | const encodedData = await this._load(); 32 | if (!encodedData) { 33 | return null; 34 | } 35 | return this.serializer.deserialize(encodedData); 36 | } 37 | 38 | onChange(callback: Listener["callback"]): void { 39 | this.listeners.add({ 40 | callback, 41 | }); 42 | } 43 | 44 | offChange(callback: Listener["callback"]): void { 45 | for (let listener of this.listeners) { 46 | if (listener.callback === callback) { 47 | this.listeners.delete(listener); 48 | return; 49 | } 50 | } 51 | } 52 | 53 | protected triggerChange() { 54 | this.load() 55 | .then(value => { 56 | this.listeners.forEach(l => l.callback({value})); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/modules/persistence/serializer.ts: -------------------------------------------------------------------------------- 1 | // convert to web stream api when Node.js >= v21 2 | export interface Serializer { 3 | serialize(value: T): string, 4 | deserialize(value: string): T, 5 | } 6 | 7 | type JSONPrimitive = string | number | boolean | null | undefined; 8 | type JSONObject = {[K in keyof T & string]?: JSONValue}; 9 | type JSONArray = JSONValue[]; 10 | export type JSONValue = JSONPrimitive | JSONObject | JSONArray; 11 | 12 | export class JSONSerializer> implements Serializer { 13 | serialize(value: T): string { 14 | return JSON.stringify(value); 15 | } 16 | 17 | deserialize(value: string): T { 18 | return JSON.parse(value); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client-hooks/index.ts: -------------------------------------------------------------------------------- 1 | export {default as useLoadRegions} from "./use-load-regions"; 2 | export {default as useLoadBuckets} from "./use-load-buckets"; 3 | export {default as useHeadFile} from "./use-head-file"; 4 | export {default as useLoadFiles} from "./use-load-files"; 5 | export {default as useLoadDomains} from "./use-load-domains"; 6 | export * from "./use-load-domains"; 7 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client-hooks/use-frozen-info.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | import {toast} from "react-hot-toast"; 3 | 4 | import {BackendMode} from "@common/qiniu"; 5 | 6 | import {getFrozenInfo} from "@renderer/modules/qiniu-client"; 7 | import {AkItem} from "@renderer/modules/auth"; 8 | 9 | export interface FrozenInfo { 10 | isLoading: boolean, 11 | status?: "Normal" | "Frozen" | "Unfreezing" | "Unfrozen", 12 | expiryDate?: number, 13 | } 14 | 15 | interface useFrozenInfoProps { 16 | user: AkItem | null, 17 | regionId?: string, 18 | bucketName?: string, 19 | filePath?: string, 20 | preferBackendMode?: BackendMode, 21 | } 22 | 23 | const useFrozenInfo = ({ 24 | user, 25 | regionId, 26 | bucketName, 27 | filePath, 28 | preferBackendMode, 29 | }: useFrozenInfoProps) => { 30 | const [frozenInfo, setFrozenInfo] = useState({ 31 | isLoading: true, 32 | }); 33 | 34 | const fetchFrozenInfo = () => { 35 | if (!user || !regionId || !bucketName || filePath === undefined) { 36 | return; 37 | } 38 | 39 | setFrozenInfo({ 40 | isLoading: true, 41 | }); 42 | 43 | const opt = { 44 | id: user.accessKey, 45 | secret: user.accessSecret, 46 | endpointType: user.endpointType, 47 | preferKodoAdapter: preferBackendMode === BackendMode.Kodo, 48 | preferS3Adapter: preferBackendMode === BackendMode.S3, 49 | }; 50 | 51 | getFrozenInfo( 52 | regionId, 53 | bucketName, 54 | filePath, 55 | opt, 56 | ) 57 | .then(({status, expiryDate}) => { 58 | setFrozenInfo({ 59 | isLoading: false, 60 | status: status, 61 | expiryDate: expiryDate?.getTime(), 62 | }) 63 | }) 64 | .catch(err => { 65 | setFrozenInfo({ 66 | isLoading: false, 67 | }); 68 | toast.error(err.toString()); 69 | }); 70 | } 71 | 72 | return { 73 | frozenInfo, 74 | fetchFrozenInfo, 75 | }; 76 | }; 77 | 78 | export default useFrozenInfo; 79 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client-hooks/use-head-file.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | import {toast} from "react-hot-toast"; 3 | import {ObjectInfo} from "kodo-s3-adapter-sdk/dist/adapter"; 4 | 5 | import {BackendMode} from "@common/qiniu"; 6 | import StorageClass from "@common/models/storage-class"; 7 | 8 | import {AkItem} from "@renderer/modules/auth"; 9 | import {headFile} from "@renderer/modules/qiniu-client"; 10 | 11 | interface HeadFileState { 12 | isLoading: boolean, 13 | fileInfo?: ObjectInfo, 14 | } 15 | 16 | interface useHeadFileProps { 17 | user: AkItem | null, 18 | regionId?: string, 19 | bucketName?: string, 20 | filePath?: string, 21 | storageClasses?: StorageClass[], 22 | preferBackendMode?: BackendMode, 23 | } 24 | 25 | const useHeadFile = ({ 26 | user, 27 | regionId, 28 | bucketName, 29 | filePath, 30 | storageClasses, 31 | preferBackendMode, 32 | }: useHeadFileProps) => { 33 | const [headFileState, setHeadFileState] = useState({ 34 | isLoading: true, 35 | }); 36 | 37 | const fetchFileInfo = () => { 38 | if (!user || !regionId || !bucketName || filePath === undefined || !storageClasses) { 39 | return; 40 | } 41 | 42 | setHeadFileState({ 43 | isLoading: true, 44 | }); 45 | 46 | const opt = { 47 | id: user.accessKey, 48 | secret: user.accessSecret, 49 | endpointType: user.endpointType, 50 | storageClasses: storageClasses, 51 | preferKodoAdapter: preferBackendMode === BackendMode.Kodo, 52 | preferS3Adapter: preferBackendMode === BackendMode.S3, 53 | }; 54 | 55 | headFile( 56 | regionId, 57 | bucketName, 58 | filePath, 59 | opt, 60 | ) 61 | .then(data => { 62 | setHeadFileState({ 63 | isLoading: false, 64 | fileInfo: data, 65 | }); 66 | }) 67 | .catch(err => { 68 | setHeadFileState({ 69 | isLoading: false, 70 | }); 71 | toast.error(err.toString()); 72 | }); 73 | }; 74 | 75 | return { 76 | headFileState, 77 | fetchFileInfo, 78 | }; 79 | }; 80 | 81 | export default useHeadFile; 82 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client-hooks/use-load-buckets.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | import {AkItem} from "@renderer/modules/auth"; 3 | import {BucketItem, listAllBuckets} from "@renderer/modules/qiniu-client"; 4 | 5 | interface LoadBucketsState { 6 | loading: boolean, 7 | buckets: BucketItem[], 8 | } 9 | 10 | interface UseLoadFilesProps { 11 | user: AkItem | null, 12 | } 13 | 14 | export default function useLoadBuckets({ 15 | user, 16 | }: UseLoadFilesProps) { 17 | const [loadBucketsState, setLoadBucketsState] = useState({ 18 | loading: true, 19 | buckets: [], 20 | }); 21 | 22 | const loadBuckets = async () => { 23 | if (!user) { 24 | return; 25 | } 26 | 27 | const opt = { 28 | id: user.accessKey, 29 | secret: user.accessSecret, 30 | endpointType: user.endpointType, 31 | }; 32 | 33 | setLoadBucketsState(s => ({ 34 | ...s, 35 | loading: true, 36 | })); 37 | try { 38 | const buckets = await listAllBuckets(opt); 39 | setLoadBucketsState({ 40 | loading: false, 41 | buckets, 42 | }); 43 | } finally { 44 | setLoadBucketsState(v => ({ 45 | ...v, 46 | loading: false, 47 | })); 48 | } 49 | }; 50 | 51 | return { 52 | loadBucketsState, 53 | loadBuckets, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/bucket-item.ts: -------------------------------------------------------------------------------- 1 | import {BackendMode} from "@common/qiniu"; 2 | 3 | export interface BucketItem { 4 | id: string; 5 | name: string; 6 | createDate: Date; 7 | regionId?: string; 8 | regionName?: string, 9 | grantedPermission?: 'readonly' | 'readwrite', 10 | preferBackendMode?: BackendMode, 11 | remark?: string, 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/buckets.ts: -------------------------------------------------------------------------------- 1 | import { GetAdapterOptionParam, getDefaultClient } from "./common" 2 | import {BucketItem} from "./bucket-item"; 3 | 4 | export async function listAllBuckets(opt: GetAdapterOptionParam): Promise { 5 | return await getDefaultClient(opt).enter("listBuckets", async client => { 6 | return await client.listBuckets(); 7 | }); 8 | } 9 | 10 | export async function createBucket(region: string, bucket: string, opt: GetAdapterOptionParam): Promise { 11 | await getDefaultClient(opt).enter("createBucket", async client => { 12 | await client.createBucket(region, bucket); 13 | }, { 14 | targetBucket: bucket, 15 | }); 16 | } 17 | 18 | export async function deleteBucket(region: string, bucket: string, opt: GetAdapterOptionParam): Promise { 19 | await getDefaultClient(opt).enter("deleteBucket", async client => { 20 | await client.deleteBucket(region, bucket); 21 | }, { 22 | targetBucket: bucket, 23 | }); 24 | } 25 | 26 | export async function updateBucketRemark(bucket: string, remark: string, opt: GetAdapterOptionParam): Promise { 27 | await getDefaultClient(opt).enter("updateBucketRemark", async client => { 28 | await client.updateBucketRemark(bucket, remark); 29 | }, { 30 | targetBucket: bucket, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./common"; 3 | export * from "./utils"; 4 | export * from "./buckets"; 5 | export * from "./bucket-item"; 6 | export * from "./files"; 7 | export * as FileItem from "./file-item"; 8 | export * from "./regions"; 9 | export * from "./share"; 10 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/regions.ts: -------------------------------------------------------------------------------- 1 | import {Region} from "kodo-s3-adapter-sdk"; 2 | 3 | import {GetAdapterOptionParam, getDefaultClient, getRegionService} from "./common"; 4 | 5 | export async function getRegions( 6 | opt: GetAdapterOptionParam, 7 | ): Promise { 8 | return await getDefaultClient(opt).enter("getRegions", async (_, regionOptions) => { 9 | return await getRegionService(opt).getAllRegions(regionOptions); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/share.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CheckShareOptions, 3 | CreateShareOptions, 4 | CreateShareResult, 5 | VerifyShareOptions, 6 | VerifyShareResult, 7 | } from "kodo-s3-adapter-sdk/dist/share-service"; 8 | 9 | import {getShareService, GetShareServiceOptions} from "@renderer/modules/qiniu-client/common"; 10 | 11 | export async function getShareApiHosts( 12 | portalHosts: string[], 13 | ): Promise { 14 | const shareService = await getShareService({}); 15 | return await shareService.getApiHosts(portalHosts); 16 | } 17 | 18 | export async function createShare( 19 | param: CreateShareOptions, 20 | opt: Required, 21 | ): Promise { 22 | const shareService = await getShareService(opt); 23 | return await shareService.createShare(param); 24 | } 25 | 26 | export async function checkShare( 27 | param: CheckShareOptions, 28 | opt: GetShareServiceOptions, 29 | ): Promise { 30 | const shareService = await getShareService(opt); 31 | await shareService.checkShare(param); 32 | } 33 | 34 | export async function verifyShare( 35 | param: VerifyShareOptions, 36 | opt: GetShareServiceOptions, 37 | ): Promise { 38 | const shareService = await getShareService(opt); 39 | return await shareService.verifyShare(param); 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/modules/qiniu-client/types.ts: -------------------------------------------------------------------------------- 1 | export interface RegionSetting { 2 | identifier: string, 3 | label: string, 4 | endpoint: string, 5 | } 6 | 7 | export interface Endpoint { 8 | ucUrl: string, 9 | regions: RegionSetting[], 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/fetch-version.ts: -------------------------------------------------------------------------------- 1 | import request from "request"; 2 | 3 | import Duration from "@common/const/duration"; 4 | import {upgrade} from "@renderer/customize"; 5 | 6 | import {compareVersion} from "./utils"; 7 | 8 | const CACHE_DURATION = Duration.Hour; 9 | 10 | interface UpdateInfo { 11 | referer: string, 12 | downloadPageUrl: string, 13 | latestVersion: string, 14 | latestDownloadUrl: string, 15 | lastCheckTimestamp: number, 16 | } 17 | 18 | let cachedUpdateInfo: UpdateInfo = { 19 | referer: "", 20 | downloadPageUrl: "", 21 | latestVersion: "", 22 | latestDownloadUrl: "", 23 | lastCheckTimestamp: 0, 24 | } 25 | 26 | export async function fetchReleaseNote(version: string): Promise { 27 | const resp = await new Promise((resolve, reject) => { 28 | request.get( 29 | { 30 | url: `${upgrade.release_notes_url}${version}.md`, 31 | }, 32 | (error, response) => { 33 | if(error) { 34 | reject(error); 35 | return; 36 | } 37 | resolve(response); 38 | }); 39 | }); 40 | if (Math.floor(resp.statusCode / 100) !== 2) { 41 | return "Not Found"; 42 | } 43 | return resp.body; 44 | } 45 | 46 | 47 | export async function fetchUpdate(): Promise { 48 | if (Date.now() - cachedUpdateInfo.lastCheckTimestamp <= CACHE_DURATION) { 49 | return cachedUpdateInfo; 50 | } 51 | const resp = await new Promise((resolve, reject) => { 52 | request.get( 53 | { 54 | url: upgrade.check_url, 55 | }, 56 | (error, response) => { 57 | if (error || Math.floor(response.statusCode / 100) !== 2) { 58 | reject(error); 59 | return; 60 | } 61 | resolve(response); 62 | } 63 | ); 64 | }); 65 | const respJson = JSON.parse(resp.body); 66 | cachedUpdateInfo = { 67 | referer: respJson.referer, 68 | downloadPageUrl: respJson["download_page"], 69 | latestVersion: respJson.version, 70 | latestDownloadUrl: respJson.downloads?.[process.platform]?.[process.arch] ?? "", 71 | lastCheckTimestamp: Date.now(), 72 | }; 73 | return cachedUpdateInfo; 74 | } 75 | 76 | /** 77 | * return null if there isn't a new version 78 | */ 79 | export async function fetchLatestVersion(currentVersion: string): Promise { 80 | const {latestVersion, latestDownloadUrl} = await fetchUpdate(); 81 | if (!latestDownloadUrl) { 82 | return null; 83 | } 84 | return compareVersion(currentVersion, latestVersion) < 0 85 | ? latestVersion 86 | : null; 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export { 3 | fetchLatestVersion, 4 | fetchReleaseNote, 5 | fetchUpdate, 6 | } from "./fetch-version"; 7 | export { 8 | downloadLatestVersion, 9 | } from "./download-whole"; 10 | 11 | import fsPromises from "fs/promises"; 12 | import path from "path"; 13 | import {app, config_path} from "@common/const/app-config"; 14 | import {Migrator} from "./migrator"; 15 | import {compareVersion} from "./utils"; 16 | 17 | async function prevVersionGetter(): Promise { 18 | const currVersionFilePath = path.join(config_path, ".prev_version"); 19 | let version: string; 20 | try { 21 | version = (await fsPromises.readFile(currVersionFilePath)).toString(); 22 | } catch { 23 | // v2.1.2 is the first version added migrator 24 | version = "2.1.2"; 25 | } 26 | return version; 27 | } 28 | 29 | async function currVersionGetter(): Promise { 30 | return app.version; 31 | } 32 | 33 | async function prevVersionSetter(version: string): Promise { 34 | const currVersionFilePath = path.join(config_path, ".prev_version"); 35 | try { 36 | await fsPromises.access(config_path); 37 | } catch { 38 | await fsPromises.mkdir(config_path, {recursive: true}); 39 | } 40 | await fsPromises.writeFile(currVersionFilePath, version); 41 | } 42 | 43 | export async function shouldMigrate(): Promise { 44 | const prev = await prevVersionGetter(); 45 | const curr = await currVersionGetter(); 46 | const res = compareVersion(curr, prev); 47 | if (res === 0) { 48 | return false; 49 | } else if (res > 0) { 50 | return "upgrade"; 51 | } else { 52 | return "downgrade"; 53 | } 54 | } 55 | 56 | export async function getMigrator(): Promise { 57 | const result = new Migrator({ 58 | prevVersionGetter, 59 | currVersionGetter, 60 | prevVersionSetter, 61 | }); 62 | 63 | const migrateSteps = await import("./migrate-steps"); 64 | result.register(...Object.values(migrateSteps)); 65 | 66 | return result; 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/migrate-steps/2.2.0/index.ts: -------------------------------------------------------------------------------- 1 | import {MigrateStep} from "../../migrator"; 2 | 3 | import up from "./upgrade"; 4 | import down from "./downgrade"; 5 | 6 | export const v2_2_0: MigrateStep = { 7 | version: "2.2.0", 8 | up, 9 | down, 10 | }; 11 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/migrate-steps/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./2.2.0"; 2 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {compareVersion} from "./utils"; 2 | 3 | describe("test update app utils", () => { 4 | describe("compareVersion", () => { 5 | it("should return 0 for equal versions", () => { 6 | expect(compareVersion("1.0.0", "1.0.0")).toBe(0); 7 | expect(compareVersion("2.3.4", "2.3.4")).toBe(0); 8 | expect(compareVersion("0", "0")).toBe(0); 9 | }); 10 | 11 | it("should return 1 for a greater version", () => { 12 | expect(compareVersion("2.0.0", "1.0.0")).toBeGreaterThan(0); 13 | expect(compareVersion("2.3.4", "2.2.4")).toBeGreaterThan(0); 14 | expect(compareVersion("1.0.1", "1.0.0")).toBeGreaterThan(0); 15 | expect(compareVersion("1.1", "1.0")).toBeGreaterThan(0); 16 | }); 17 | 18 | it("should return -1 for a lesser version", () => { 19 | expect(compareVersion("1.0.0", "2.0.0")).toBeLessThan(0); 20 | expect(compareVersion("2.2.4", "2.3.4")).toBeLessThan(0); 21 | expect(compareVersion("1.0.0", "1.0.1")).toBeLessThan(0); 22 | expect(compareVersion("1.0", "1.1")).toBeLessThan(0); 23 | }); 24 | 25 | it("should handle missing segments as 0", () => { 26 | expect(compareVersion("1.0.0", "1")).toBe(0); 27 | expect(compareVersion("1.2.0", "1.2")).toBe(0); 28 | expect(compareVersion("1.0.0", "1.0")).toBe(0); 29 | expect(compareVersion("0.0.0", "")).toBe(0); 30 | }); 31 | 32 | it("should ignore alphabets after numbers", () => { 33 | expect(compareVersion("1.0.0", "1.0.0-dev")).toBe(0); 34 | expect(compareVersion("1.0.0", "1.0-alpha.0-dev")).toBe(0); 35 | expect(compareVersion("1.0.0", "1.0a.0b")).toBe(0); 36 | }) 37 | 38 | // it("should handle non-numeric segments", () => { 39 | // expect(compareVersion("1.0.0", "a.b.c")).toBe(?); 40 | // expect(compareVersion("1.2.3", "x.y.z")).toBe(?); 41 | // expect(compareVersion("1.0.0", "1.x")).toBe(?); 42 | // expect(compareVersion("1.0.0", "1.0.x")).toBe(?); 43 | // expect(compareVersion("1.0.0-alpha", "1.0.x")).toBe(?); 44 | // expect(compareVersion("1.0.0-dev", "1.0.x")).toBe(?); 45 | // }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/renderer/modules/update-app/utils.ts: -------------------------------------------------------------------------------- 1 | export function compareVersion(a: string, b: string) { 2 | const aArr = a.split("."); 3 | const bArr = b.split("."); 4 | 5 | const len = Math.max(aArr.length, bArr.length); 6 | 7 | for (let i = 0; i < len; i++) { 8 | const aSeg = parseInt(aArr[i]) || 0; 9 | const bSeg = parseInt(bArr[i]) || 0; 10 | 11 | if (aSeg > bSeg) { 12 | return 1; 13 | } else if (aSeg < bSeg) { 14 | return -1; 15 | } 16 | } 17 | return 0; 18 | } 19 | 20 | // export function compareVersion(a: string, b: string, humanist?: false): number 21 | // export function compareVersion(a: string, b: string, humanist: true): | ">" | "<" | "=" 22 | // export function compareVersion(a: string, b: string, humanist = false): number | ">" | "<" | "=" { 23 | // const aArr = a.split("."); 24 | // const bArr = b.split("."); 25 | // 26 | // const len = Math.max(aArr.length, bArr.length); 27 | // 28 | // for (let i = 0; i < len; i++) { 29 | // const aSeg = parseInt(aArr[i]) || 0; 30 | // const bSeg = parseInt(bArr[i]) || 0; 31 | // 32 | // if (aSeg > bSeg) { 33 | // return humanist ? ">" : 1; 34 | // } else if (aSeg < bSeg) { 35 | // return humanist ? "<" : -1; 36 | // } 37 | // } 38 | // return humanist ? "=" : 0; 39 | // } 40 | -------------------------------------------------------------------------------- /src/renderer/modules/user-config-store/error-handler.ts: -------------------------------------------------------------------------------- 1 | import {toast} from "react-hot-toast"; 2 | 3 | import * as Logger from "@renderer/modules/local-logger"; 4 | 5 | export default function handleLoadError(err: Error): void { 6 | toast.error(err.message); 7 | Logger.error(err); 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/modules/user-config-store/index.ts: -------------------------------------------------------------------------------- 1 | export {default as UserConfigStore} from "./user-config-store"; 2 | export * from "./user-config-store"; 3 | export {default as appPreferences} from "./app-preferences"; 4 | export * from "./app-preferences"; 5 | export * from "./endpoint-config"; 6 | export {default as useBookmarkPath} from "./bookmark-path"; 7 | export * from "./bookmark-path"; 8 | export {default as useExternalPath} from "./external-path"; 9 | export * from "./external-path"; 10 | -------------------------------------------------------------------------------- /src/renderer/pages/browse-share/contents.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Region} from "kodo-s3-adapter-sdk"; 3 | 4 | import {BackendMode} from "@common/qiniu"; 5 | 6 | import {useAuth} from "@renderer/modules/auth"; 7 | import {Provider as FileOperationProvider} from "@renderer/modules/file-operation"; 8 | import {BucketItem} from "@renderer/modules/qiniu-client"; 9 | 10 | import Files from "../browse/files"; 11 | 12 | interface ContentsProps { 13 | toggleRefresh?: boolean, 14 | } 15 | 16 | const Contents: React.FC = ({ 17 | toggleRefresh 18 | }) => { 19 | const {shareSession} = useAuth(); 20 | 21 | if (!shareSession) { 22 | return ( 23 | <> 24 | no share session 25 | 26 | ); 27 | } 28 | 29 | const bucket: BucketItem = { 30 | id: shareSession.bucketId, 31 | name: shareSession.bucketName, 32 | // can't get create data of a share path. 33 | createDate: new Date(NaN), 34 | regionId: shareSession.regionS3Id, 35 | preferBackendMode: BackendMode.S3, 36 | grantedPermission: shareSession.permission === "READWRITE" ? "readwrite" : "readonly", 37 | } 38 | const region: Region = new Region( 39 | "", 40 | shareSession.regionS3Id, 41 | ); 42 | region.s3Urls = [shareSession.endpoint] 43 | 44 | return ( 45 | 49 |
    57 | 62 |
    63 |
    64 | ); 65 | }; 66 | 67 | export default Contents; 68 | -------------------------------------------------------------------------------- /src/renderer/pages/browse-share/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useState} from "react"; 2 | import lodash from "lodash"; 3 | import {toast} from "react-hot-toast"; 4 | 5 | import {useAuth} from "@renderer/modules/auth"; 6 | import {ADDR_KODO_PROTOCOL} from "@renderer/const/kodo-nav"; 7 | import { 8 | KodoAddress, 9 | KodoNavigator, 10 | Provider as KodoNavigatorProvider, 11 | } from "@renderer/modules/kodo-address"; 12 | import {useBookmarkPath} from "@renderer/modules/user-config-store"; 13 | 14 | import LoadingHolder from "@renderer/components/loading-holder"; 15 | import KodoAddressBar from "@renderer/components/kodo-address-bar"; 16 | 17 | import Transfer from "../browse/transfer"; 18 | import Contents from "./contents"; 19 | 20 | interface BrowseShareProps {} 21 | 22 | const BrowseShare: React.FC = () => { 23 | const {currentUser, shareSession} = useAuth(); 24 | 25 | const [kodoNavigator, setKodoNavigator] = useState(); 26 | const [toggleRefresh, setToggleRefresh] = useState(true); 27 | 28 | const toggleRefreshThrottled = useCallback(lodash.throttle(() => { 29 | setToggleRefresh(v => !v); 30 | }, 300), []); 31 | 32 | const {setHome} = useBookmarkPath(currentUser); 33 | 34 | // initial kodo navigator 35 | useEffect(() => { 36 | if (!currentUser || !shareSession) { 37 | return; 38 | } 39 | const homeAddress: KodoAddress = { 40 | protocol: ADDR_KODO_PROTOCOL, 41 | path: `${shareSession.bucketName}/${shareSession.prefix}`, 42 | } 43 | const kodoNav = new KodoNavigator({ 44 | defaultProtocol: ADDR_KODO_PROTOCOL, 45 | maxHistory: 100, 46 | initAddress: homeAddress, 47 | lockPrefix: homeAddress.path, 48 | }); 49 | setKodoNavigator(kodoNav); 50 | setHome(homeAddress) 51 | .catch(e=> { 52 | toast.error(e.toString()); 53 | }); 54 | }, [currentUser]); 55 | 56 | // render 57 | if (!currentUser) { 58 | return ( 59 | <>not sign in 60 | ); 61 | } 62 | 63 | if (!kodoNavigator) { 64 | return ( 65 | 66 | ); 67 | } 68 | 69 | return ( 70 | 71 | 74 | 77 | 80 | 81 | ); 82 | }; 83 | 84 | export default BrowseShare; 85 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/buckets/bucket-grid-cell.tsx: -------------------------------------------------------------------------------- 1 | import React, {MouseEventHandler} from "react"; 2 | import {Card} from "react-bootstrap"; 3 | 4 | import {BucketItem} from "@renderer/modules/qiniu-client"; 5 | 6 | interface BucketCellProps { 7 | data: BucketItem, 8 | isSelected: boolean, 9 | onClick: (bucket: BucketItem) => void, 10 | onDoubleClick: (bucket: BucketItem) => void, 11 | } 12 | 13 | const BucketCell: React.FC = ({ 14 | data, 15 | isSelected, 16 | onClick, 17 | onDoubleClick, 18 | }) => { 19 | const handleClick: MouseEventHandler = (e) => { 20 | switch (e.detail) { 21 | case 1: { 22 | return onClick(data); 23 | } 24 | case 2: { 25 | return onDoubleClick(data); 26 | } 27 | } 28 | } 29 | 30 | // icon class name 31 | let iconClassName = "bi bi-database-fill me-1 text-brown"; 32 | if (data.grantedPermission === "readonly") { 33 | iconClassName = "bic bic-database-fill-eye me-1 text-slate"; 34 | } else if (data.grantedPermission === "readwrite") { 35 | iconClassName = "bic bic-database-fill-pencil me-1 text-slate"; 36 | } 37 | 38 | return ( 39 | 44 | 48 | 49 | 50 | {data.name} 51 | 52 | 53 | {data.regionName || data.regionId} 54 | 55 | 56 | { 57 | isSelected && 58 |
    59 | 60 |
    61 | } 62 |
    63 | ); 64 | }; 65 | 66 | export default BucketCell; 67 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/buckets/bucket-grid.scss: -------------------------------------------------------------------------------- 1 | .bucket-grid { 2 | & .base-grid { 3 | display: flex; 4 | flex-wrap: wrap; 5 | align-content: flex-start; 6 | overflow: auto; 7 | // 1.4rem is the padding-bottom of base-grid 8 | height: calc(100% - 1.4rem); 9 | } 10 | 11 | & .base-cell { 12 | flex: 0 0 240px; 13 | min-width: 0; 14 | height: 78px; 15 | } 16 | 17 | & .bucket-cell { 18 | position: relative; 19 | 20 | & i.card-img { 21 | margin-left: 0.25rem; 22 | font-size: 2.5rem; 23 | } 24 | 25 | & .selected-mark { 26 | position: absolute; 27 | right: 0; 28 | bottom: 0; 29 | font-size: 1.25rem; 30 | line-height: 1; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/buckets/bucket-grid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import {BucketItem} from "@renderer/modules/qiniu-client"; 4 | import {useKodoNavigator} from "@renderer/modules/kodo-address"; 5 | import EmptyHolder from "@renderer/components/empty-holder"; 6 | 7 | import BucketCell from "./bucket-grid-cell"; 8 | import "./bucket-grid.scss"; 9 | 10 | interface BucketGridProps { 11 | loading: boolean, 12 | buckets: BucketItem[], 13 | selectedBucket: BucketItem | null, 14 | onChangeSelectedBucket: (bucket: BucketItem | null) => void, 15 | } 16 | 17 | const BucketGrid: React.FC = ({ 18 | loading, 19 | buckets, 20 | selectedBucket, 21 | onChangeSelectedBucket, 22 | }) => { 23 | const {goTo} = useKodoNavigator(); 24 | 25 | const handleClickCell = (bucket: BucketItem) => { 26 | if (bucket.name === selectedBucket?.name) { 27 | onChangeSelectedBucket(null); 28 | return; 29 | } 30 | onChangeSelectedBucket(bucket); 31 | }; 32 | 33 | const handleDoubleClickCell = (bucket: BucketItem) => { 34 | goTo({ 35 | path: `${bucket.name}/`, 36 | }); 37 | }; 38 | 39 | return ( 40 |
    41 |
    42 | { 43 | buckets.map(bucket => ( 44 |
    50 | 56 |
    57 | )) 58 | } 59 | { 60 | !buckets.length && 61 | 62 | } 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default BucketGrid; 69 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/external-paths/external-path-table-row.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Form} from "react-bootstrap"; 3 | 4 | import {ExternalPathItem} from "@renderer/modules/user-config-store"; 5 | 6 | export interface ExternalPathRowData extends ExternalPathItem{ 7 | regionName: string, 8 | } 9 | 10 | interface ExternalPathTableRowProps { 11 | data: ExternalPathRowData, 12 | isSelected: boolean, 13 | onClickRow: (item: ExternalPathRowData) => void, 14 | onClickPath: (item: ExternalPathRowData) => void, 15 | } 16 | 17 | const ExternalPathTableRow: React.FC = ({ 18 | data, 19 | isSelected, 20 | onClickRow, 21 | onClickPath, 22 | }) => { 23 | return ( 24 | onClickRow(data)}> 25 | 26 | { 32 | }} 33 | /> 34 | 35 | 36 | onClickPath(data)} 39 | > 40 | 41 | {data.protocol}{data.path} 42 | 43 | 44 | 45 | {data.regionName} 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default ExternalPathTableRow; 52 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/external-paths/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {toast} from "react-hot-toast"; 3 | import {Region} from "kodo-s3-adapter-sdk"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import {useAuth} from "@renderer/modules/auth"; 7 | import {useExternalPath} from "@renderer/modules/user-config-store"; 8 | 9 | import ExternalPathToolBar from "./external-path-tool-bar"; 10 | import ExternalPathTable from "./external-path-table"; 11 | import {ExternalPathRowData} from "./external-path-table-row"; 12 | 13 | interface ExternalPathsProps { 14 | toggleRefresh?: boolean, 15 | regions: Region[], 16 | } 17 | 18 | const ExternalPaths: React.FC = ({ 19 | toggleRefresh, 20 | regions, 21 | }) => { 22 | const {currentLanguage, translate} = useI18n(); 23 | const {currentUser} = useAuth(); 24 | 25 | const [selectedPath, setSelectedPath] = useState(null); 26 | 27 | // search path 28 | const [pathSearchText, setPathSearchText] = useState(""); 29 | const handleSearchPath = (target: string) => { 30 | setPathSearchText(target); 31 | }; 32 | 33 | const { 34 | externalPathState, 35 | externalPathData, 36 | loadExternalPaths, 37 | } = useExternalPath(currentUser); 38 | 39 | useEffect(() => { 40 | loadExternalPaths() 41 | .catch(err => { 42 | toast.error(`${translate("common.failed")}: ${err}`); 43 | }); 44 | }, [toggleRefresh]); 45 | 46 | // computed state 47 | const pathsToRender = externalPathData.list 48 | .filter(p => p.path.includes(pathSearchText)) 49 | .map(p => { 50 | const region = regions.find(r => r.s3Id === p.regionId); 51 | return { 52 | ...p, 53 | regionName: 54 | region?.translatedLabels?.[currentLanguage] ?? 55 | region?.label ?? 56 | p.regionId, 57 | }; 58 | }); 59 | 60 | return ( 61 | <> 62 | 66 | 72 | 73 | ); 74 | }; 75 | 76 | export default ExternalPaths; 77 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/auto-fill-first-view/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {PropsWithChildren, useEffect} from "react"; 2 | 3 | interface AutoFillFirstViewProps { 4 | height: number, 5 | rowData: T[], 6 | rowHeight: number, 7 | onLoadMore: () => void, 8 | } 9 | 10 | const AutoFillFirstView: React.FC> = ({ 11 | height, 12 | rowData, 13 | rowHeight, 14 | onLoadMore, 15 | children, 16 | }) => { 17 | useEffect(() => { 18 | if (rowData.length * rowHeight > height) { 19 | return; 20 | } 21 | onLoadMore(); 22 | }, [height, rowData, rowHeight]); 23 | 24 | return ( 25 | <> 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | export default AutoFillFirstView; 32 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/const.ts: -------------------------------------------------------------------------------- 1 | export const LOAD_MORE_THRESHOLD = 100; 2 | export const TABLE_ROW_HEIGHT = 34; 3 | export const GRID_CELL_WIDTH = 240; 4 | export const GRID_CELL_HEIGHT = 78; 5 | 6 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/file-grid/file-cell.tsx: -------------------------------------------------------------------------------- 1 | import React, {MouseEvent, MouseEventHandler} from "react"; 2 | import {Card} from "react-bootstrap"; 3 | import classNames from "classnames"; 4 | 5 | import {byteSizeFormat} from "@common/const/byte-size"; 6 | import {useI18n} from "@renderer/modules/i18n"; 7 | import {FileItem} from "@renderer/modules/qiniu-client"; 8 | 9 | export type CellData = FileItem.Item & { 10 | id: string, 11 | isSelected: boolean, 12 | _index: number, 13 | } 14 | 15 | interface FileCellProps { 16 | data: CellData, 17 | onClick: (event: MouseEvent, f: CellData) => void, 18 | onDoubleClick: (event: MouseEvent, f: CellData) => void, 19 | } 20 | 21 | const FileCell: React.FC = ({ 22 | data, 23 | onClick, 24 | onDoubleClick, 25 | }) => { 26 | const {translate} = useI18n(); 27 | 28 | const handleClick: MouseEventHandler = (e) => { 29 | switch (e.detail) { 30 | case 1: { 31 | return onClick(e, data); 32 | } 33 | case 2: { 34 | return onDoubleClick(e, data); 35 | } 36 | } 37 | } 38 | 39 | return ( 40 | 45 | 53 | 54 | 55 | {data.name} 56 | 57 | 58 | { 59 | FileItem.isItemFile(data) 60 | ? byteSizeFormat(data.size) 61 | : translate("common.directory") 62 | } 63 | 64 | 65 | { 66 | data.isSelected && 67 |
    68 | 69 |
    70 | } 71 |
    72 | ); 73 | }; 74 | 75 | export default FileCell; 76 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/file-grid/file-grid.scss: -------------------------------------------------------------------------------- 1 | .file-grid { 2 | & .file-cell { 3 | position: relative; 4 | 5 | & i.card-img { 6 | margin-left: 0.25rem; 7 | font-size: 2.5rem; 8 | } 9 | 10 | & .selected-mark { 11 | position: absolute; 12 | right: 0; 13 | bottom: 0; 14 | font-size: 1.25rem; 15 | line-height: 1; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/file-table/columns/file-checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Form} from "react-bootstrap"; 3 | 4 | import {RowCellDataProps} from "../../types" 5 | 6 | export interface FileCheckboxHeaderProps { 7 | isSelectedAll: boolean, 8 | onToggleSelectAll: (selectAll: boolean) => void, 9 | } 10 | 11 | export const FileCheckboxHeader: React.FC = ({ 12 | isSelectedAll, 13 | onToggleSelectAll, 14 | }) => { 15 | return ( 16 | onToggleSelectAll(!isSelectedAll)} 21 | /> 22 | ); 23 | }; 24 | 25 | export interface FileCheckboxCellProps { 26 | } 27 | 28 | const FileCheckbox: React.FC & FileCheckboxCellProps> = ({ 29 | cellData, 30 | }) => { 31 | // select action will be handled on row, 32 | // so there isn't a onChange handler. 33 | 34 | return ( 35 | { 41 | }} 42 | /> 43 | ) 44 | }; 45 | 46 | export default FileCheckbox; 47 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/file-table/file-table.scss: -------------------------------------------------------------------------------- 1 | .file-base-table { 2 | // make sure last one can be operated 3 | & .BaseTable__body { 4 | padding-bottom: 1.4rem; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/files.scss: -------------------------------------------------------------------------------- 1 | .files-upload-zone { 2 | position: absolute; 3 | inset: 0 0 0 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/overlay-holder/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useState} from "react"; 2 | import lodash from "lodash"; 3 | 4 | import {useI18n} from "@renderer/modules/i18n"; 5 | import LoadingHolder from "@renderer/components/loading-holder"; 6 | 7 | import "./overlay-holder.scss"; 8 | 9 | interface OverlayHolderProps { 10 | onLoadMoreManually?: () => void, 11 | loadMoreFailed?: boolean, 12 | loadingMore?: boolean, 13 | } 14 | 15 | const OverlayHolder: React.FC = ({ 16 | onLoadMoreManually, 17 | loadMoreFailed, 18 | loadingMore, 19 | }) => { 20 | const {translate} = useI18n(); 21 | 22 | const [showLoadingMore, setShowLoadingMore] = useState(false); 23 | const setShowLoadingMoreDebounced = useCallback( 24 | lodash.debounce(setShowLoadingMore, 200, {leading: false, trailing: true}), 25 | []); 26 | 27 | useEffect(() => { 28 | if (!loadingMore) { 29 | setShowLoadingMoreDebounced(false); 30 | return; 31 | } 32 | setShowLoadingMore(true); 33 | }, [loadingMore]); 34 | 35 | if (loadMoreFailed) { 36 | return ( 37 | <> 38 |
    39 |
    40 | 41 | 42 | {translate("browse.fileTable.loadMoreFailed")} 43 | 44 | 51 | {translate("common.clickToRetry")} 52 | 53 |
    54 |
    55 | 56 | ); 57 | } 58 | 59 | if (showLoadingMore) { 60 | return ( 61 | <> 62 |
    63 | 68 |
    69 | 70 | ); 71 | } 72 | 73 | return null; 74 | }; 75 | 76 | export default OverlayHolder; 77 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/overlay-holder/overlay-holder.scss: -------------------------------------------------------------------------------- 1 | .load-more-layer { 2 | pointer-events: none; 3 | position: absolute; 4 | inset: auto 0 0 0; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/files/types.ts: -------------------------------------------------------------------------------- 1 | import {FileItem} from "@renderer/modules/qiniu-client"; 2 | 3 | export enum OperationName { 4 | Restore = "restore", 5 | Download = "download", 6 | GenerateLink = "generateLink", 7 | ShareDir = "shareDir", 8 | ChangeStorageClass = "changeStorageClass", 9 | Delete = "delete", 10 | } 11 | 12 | export type FileRowData = FileItem.Item & { 13 | id: string, 14 | isSelected: boolean, 15 | regionId?: string, 16 | _index: number, 17 | }; 18 | 19 | export interface RowCellDataProps { 20 | rowData: FileRowData, 21 | cellData: T, 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/pages/browse/transfer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {CreatedDirectoryReplyMessage, JobCompletedReplyMessage} from "@common/ipc-actions/upload"; 4 | 5 | import {KodoNavigator, useKodoNavigator} from "@renderer/modules/kodo-address"; 6 | 7 | import TransferPanel from "@renderer/components/transfer-panel"; 8 | 9 | interface TransferProps { 10 | onRefresh: () => void, 11 | } 12 | 13 | const Transfer: React.FC = ({ 14 | onRefresh, 15 | }) => { 16 | const {bucketName, basePath} = useKodoNavigator(); 17 | 18 | const handleUploadJobComplete = (data: JobCompletedReplyMessage["data"]["jobUiData"]) => { 19 | const baseDir = KodoNavigator.getBaseDir(`${data.to.bucket}/${data.to.key}`); 20 | if (`${bucketName}/${basePath}` === baseDir) { 21 | onRefresh(); 22 | } 23 | }; 24 | 25 | const handleCreatedDirectory = (data: CreatedDirectoryReplyMessage["data"]) => { 26 | const baseDir = KodoNavigator.getBaseDir(`${data.bucket}/${data.directoryKey}`); 27 | if (`${bucketName}/${basePath}` === baseDir) { 28 | onRefresh(); 29 | } 30 | }; 31 | 32 | return ( 33 |
    41 | 45 |
    46 | ); 47 | }; 48 | 49 | export default Transfer; 50 | -------------------------------------------------------------------------------- /src/renderer/pages/common/about-menu-item.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | 3 | import {app} from "@common/const/app-config"; 4 | 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | import {fetchLatestVersion} from "@renderer/modules/update-app"; 7 | 8 | interface AboutMenuItemProps { 9 | onHasNew?: () => void, 10 | } 11 | 12 | const AboutMenuItem: React.FC = ({ 13 | onHasNew, 14 | }) => { 15 | const {translate} = useI18n(); 16 | const [hasNewVersion, setHasNerVersion] = useState(false); 17 | 18 | useEffect(() => { 19 | fetchLatestVersion(app.version) 20 | .then(latestVersion => { 21 | if (!latestVersion) { 22 | return; 23 | } 24 | setHasNerVersion(true); 25 | onHasNew?.(); 26 | }) 27 | .catch(() => { 28 | // toast 29 | }); 30 | }, []); 31 | 32 | return ( 33 | <> 34 | {translate("top.about")} 35 | { 36 | hasNewVersion && 37 | new 38 | } 39 | 40 | ); 41 | }; 42 | 43 | export default AboutMenuItem; 44 | -------------------------------------------------------------------------------- /src/renderer/pages/exceptions/not-found.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | import {useLocation} from "react-router-dom"; 3 | 4 | import * as LocalLogger from "@renderer/modules/local-logger" 5 | import {useI18n} from "@renderer/modules/i18n"; 6 | 7 | const NotFound: React.FC = () => { 8 | const {translate} = useI18n(); 9 | let location = useLocation(); 10 | 11 | useEffect(() => { 12 | LocalLogger.warn("Not Found Page!", location); 13 | }, []); 14 | 15 | return ( 16 | <> 17 |
    404 {translate("common.notFound")}
    18 |
    {JSON.stringify(location)}
    19 | 20 | ); 21 | }; 22 | 23 | export default NotFound; 24 | -------------------------------------------------------------------------------- /src/renderer/pages/route-path.ts: -------------------------------------------------------------------------------- 1 | import {AkItem, SignInWithShareSessionOptions} from "@renderer/modules/auth" 2 | 3 | enum RoutePath { 4 | Root = "/", 5 | SignIn = "/sign-in", 6 | Browse = "/browse", 7 | BrowseShare = "/browse-share", 8 | SignOut = "/sign-out", 9 | SwitchUser = "/switch-user", 10 | } 11 | 12 | export interface ISignInState { 13 | // type: "ak" | "shareLink", 14 | type: "shareLink", 15 | data: { 16 | portalHost?: string, 17 | shareId: string, 18 | shareToken: string, 19 | extractCode?: string, 20 | }, 21 | } 22 | 23 | export type SignInState = ISignInState | undefined; 24 | 25 | export interface ISignOutState { 26 | type: "signInState", 27 | data: SignInState, 28 | } 29 | 30 | export type SignOutState = ISignOutState | undefined; 31 | 32 | interface SwitchUserStateBase { 33 | type: "ak" | "shareSession", 34 | } 35 | 36 | export interface SwitchUserStateAk extends SwitchUserStateBase { 37 | type: "ak", 38 | data: { 39 | akItem: AkItem, 40 | }, 41 | } 42 | 43 | export interface SwitchUserStateSession extends SwitchUserStateBase { 44 | type: "shareSession", 45 | data: SignInWithShareSessionOptions, 46 | } 47 | 48 | export type SwitchUserState = SwitchUserStateAk | SwitchUserStateSession | undefined; 49 | 50 | export default RoutePath; 51 | -------------------------------------------------------------------------------- /src/renderer/pages/sign-in/sign-in-form.scss: -------------------------------------------------------------------------------- 1 | .sign-in-form { 2 | & .private-endpoint-setting { 3 | transition: all 300ms; 4 | color: var(--bs-dark); 5 | 6 | &:hover, &:focus { 7 | color: var(--bs-primary); 8 | 9 | & .bi { 10 | display: inline-block; 11 | animation: spinner-border 2s linear infinite; 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/pages/sign-in/sign-in.scss: -------------------------------------------------------------------------------- 1 | .sign-in-page { 2 | margin: 12% 0 0; 3 | flex: 0 1 720px; 4 | } 5 | -------------------------------------------------------------------------------- /src/renderer/pages/sign-out/index.tsx: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | 3 | import React, {useEffect, useMemo} from "react"; 4 | import {useLocation, useNavigate} from "react-router-dom"; 5 | 6 | import {useAuth} from "@renderer/modules/auth"; 7 | import {clearAllCache} from "@renderer/modules/qiniu-client"; 8 | import {useI18n} from "@renderer/modules/i18n"; 9 | 10 | import LoadingHolder from "@renderer/components/loading-holder"; 11 | import * as AuditLog from "@renderer/modules/audit-log"; 12 | 13 | import RoutePath, {SignInState, SignOutState} from "@renderer/pages/route-path"; 14 | 15 | const SignOut: React.FC = () => { 16 | const {translate} = useI18n(); 17 | const {currentUser, signOut} = useAuth(); 18 | const navigate = useNavigate(); 19 | const {state: routeState} = useLocation() as { 20 | state: SignOutState 21 | }; 22 | 23 | const memoCurrentUser = useMemo(() => currentUser, []); 24 | 25 | useEffect(() => { 26 | clearAllCache(); 27 | 28 | ipcRenderer.send('asynchronous', {key: "signOut"}); 29 | new Promise(resolve => { 30 | // make sure work cleared. 31 | setTimeout(resolve, 2300); 32 | }) 33 | .then(() => { 34 | return signOut(); 35 | }) 36 | .then(() => { 37 | const signInState: SignInState = routeState?.data; 38 | navigate(RoutePath.SignIn, { 39 | state: signInState, 40 | }); 41 | AuditLog.log(AuditLog.Action.Logout, { 42 | from: memoCurrentUser?.accessKey ?? "", 43 | }) 44 | }); 45 | }, []); 46 | 47 | return ( 48 | 49 | ); 50 | }; 51 | 52 | export default SignOut; 53 | -------------------------------------------------------------------------------- /src/renderer/setup-app.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | import {createRoot} from "react-dom/client"; 3 | 4 | import {Alert, Spinner} from "react-bootstrap"; 5 | 6 | import {app} from "@common/const/app-config" 7 | 8 | import * as appLife from "./app-life"; 9 | 10 | interface SetupAppProps { 11 | resolve: () => void, 12 | reject: (err: Error) => void, 13 | } 14 | 15 | const SetupApp: React.FC = ({ 16 | resolve, 17 | reject, 18 | }) => { 19 | const [err, setErr] = useState(); 20 | 21 | useEffect(() => { 22 | appLife.beforeStart() 23 | .then(resolve) 24 | .catch(e => { 25 | setErr(e); 26 | reject(e); 27 | }); 28 | }, []); 29 | 30 | if (err) { 31 | return ( 32 |
    35 | 36 | 37 | Error! (v{app.version}) 38 | 39 |

    {err.message}

    40 |
    {err.stack}
    41 |
    42 |
    43 | ); 44 | } 45 | 46 | return ( 47 |
    50 | 51 |
    52 | Loading...... 53 |
    54 |
    55 | ); 56 | } 57 | 58 | 59 | export default async function setupApp() { 60 | const container = document.createElement("div"); 61 | container.className = "h-100"; 62 | document.body.prepend(container); 63 | const root = createRoot(container); 64 | await new Promise((resolve, reject) => { 65 | root.render( 66 | 67 | ); 68 | }); 69 | root.unmount(); 70 | container.remove(); 71 | window.onclose = appLife.beforeQuit; 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/static/brand/qiniu.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/kodo-browser/63e1a24d7d53337daaf8043ef0bc5b1619c6bd20/src/renderer/static/brand/qiniu.icns -------------------------------------------------------------------------------- /src/renderer/static/brand/qiniu.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/kodo-browser/63e1a24d7d53337daaf8043ef0bc5b1619c6bd20/src/renderer/static/brand/qiniu.ico -------------------------------------------------------------------------------- /src/renderer/static/brand/qiniu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/kodo-browser/63e1a24d7d53337daaf8043ef0bc5b1619c6bd20/src/renderer/static/brand/qiniu.png -------------------------------------------------------------------------------- /src/renderer/static/flv-player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | player 7 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/renderer/styles/animations/bg-colorful.scss: -------------------------------------------------------------------------------- 1 | @keyframes colorful { 2 | 20% { 3 | background-color: var(--bs-red); 4 | } 5 | 6 | 40% { 7 | background-color: var(--bs-yellow); 8 | } 9 | 10 | 60% { 11 | background-color: var(--bs-teal); 12 | } 13 | 14 | 80% { 15 | background-color: var(--bs-cyan); 16 | } 17 | 18 | 100% { 19 | background-color: var(--bs-indigo); 20 | } 21 | } 22 | 23 | .bg-colorful { 24 | color: var(--bs-white); 25 | animation: colorful 2.5s linear infinite alternate; 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/styles/animations/index.scss: -------------------------------------------------------------------------------- 1 | @import "./loading-spin.scss"; 2 | @import "./bg-colorful.scss"; 3 | @import "./invalid-text.scss"; 4 | -------------------------------------------------------------------------------- /src/renderer/styles/animations/invalid-text.scss: -------------------------------------------------------------------------------- 1 | @keyframes invalid-color { 2 | 50% { 3 | opacity: 0.3; 4 | } 5 | } 6 | 7 | .invalid-text { 8 | color: var(--bs-danger) !important; 9 | opacity: 1; 10 | animation: invalid-color 1s linear infinite; 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/styles/animations/loading-spin.scss: -------------------------------------------------------------------------------- 1 | .loading-spin { 2 | animation: spinner-border 2s linear infinite; 3 | } 4 | -------------------------------------------------------------------------------- /src/renderer/styles/base-table/index.scss: -------------------------------------------------------------------------------- 1 | .BaseTable { 2 | font-family: var(--bs-body-font-family); 3 | font-size: var(--bs-body-font-size); 4 | font-weight: var(--bs-body-font-weight); 5 | line-height: var(--bs-body-line-height); 6 | color: var(--bs-body-color); 7 | text-align: var(--bs-body-text-align); 8 | --bt-table-accent-bg: transparent; 9 | --bt-table-striped-bg: rgba(0, 0, 0, 0.05); 10 | --bt-table-hover-bg: rgba(0, 0, 0, 0.05); 11 | 12 | & .BaseTable__body { 13 | } 14 | 15 | & .BaseTable__row { 16 | border-bottom: 1px solid var(--bs-border-color); 17 | } 18 | 19 | & .BaseTable__row > * { 20 | padding: 0.25rem; 21 | box-shadow: inset 0 0 0 9999px var(--bt-table-accent-bg); 22 | } 23 | 24 | & .BaseTable__row:hover > * { 25 | --bt-table-accent-bg: var(--bt-table-hover-bg); 26 | background: transparent; 27 | } 28 | 29 | & .BaseTable__row.row-striped { 30 | background: var(--bt-table-striped-bg) 31 | } 32 | 33 | & .BaseTable__row-cell ~ .BaseTable__row-cell { 34 | border-left: 1px solid var(--bs-border-color); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/card.scss: -------------------------------------------------------------------------------- 1 | .card.card-horizontal { 2 | flex-direction: row; 3 | align-items: center; 4 | 5 | & .card-img { 6 | flex: 0 0 3rem; 7 | } 8 | 9 | & .card-body { 10 | min-width: 0; 11 | padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x) var(--bs-card-spacer-y) 0; 12 | 13 | & .card-title { 14 | font-size: 1rem; 15 | } 16 | 17 | & .card-subtitle { 18 | font-size: 0.875rem; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/index.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | 3 | @import "./root.scss"; 4 | @import "./button.scss"; 5 | @import "./card.scss"; 6 | @import "./modal.scss"; 7 | @import "./nav.scss"; 8 | @import "./utils.scss"; 9 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | --bs-modal-width: 600px; 3 | } 4 | 5 | .modal-720p { 6 | width: 648px; 7 | max-width: 90%; 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/nav.scss: -------------------------------------------------------------------------------- 1 | /* match primary color for active */ 2 | .nav-tabs { 3 | --bs-nav-tabs-link-active-color: var(--bs-link-color); 4 | } 5 | 6 | .nav { 7 | --bs-nav-link-color: $gray-700; 8 | --bs-nav-link-disabled-color: $gray-600; 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/root.scss: -------------------------------------------------------------------------------- 1 | /* override --bs-properties here */ 2 | :root { 3 | font-family: "Helvetica Neue", Helvetica, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, "Segoe UI", Roboto, "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 4 | --bs-font-sans-serif: "Helvetica Neue", Helvetica, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, "Segoe UI", Roboto, "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 5 | } 6 | 7 | /* override reboot styles here */ 8 | code { 9 | /* better for CJK font size */ 10 | font-size: 1em; 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/styles/bootstrap/utils.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --bs-highlight-bg-rgb: 255, 243, 205; 3 | } 4 | 5 | .bg-highlight { 6 | background-color: rgba(var(--bs-highlight-bg-rgb), var(--bs-bg-opacity)) !important; 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/styles/hot-toast/index.scss: -------------------------------------------------------------------------------- 1 | .hot-toast { 2 | word-break: break-all; 3 | 4 | transition: background-color, background-color 300ms ease; 5 | color: var(--bs-alert-color, var(--bs-body-color)) !important; 6 | background-color: var(--bs-alert-bg, var(--bs-body-bg)) !important; 7 | border: 1px solid var(--bs-alert-border-color, transparent) !important; 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/styles/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | /* these icons are comb by bootstrap icon from figma and re-export by iconfont */ 2 | @font-face { 3 | font-family: "bic"; /* Project id 3766549 */ 4 | src: url('@renderer/styles/iconfont/iconfont.woff2') format('woff2'), 5 | url('@renderer/styles/iconfont/iconfont.woff') format('woff') 6 | } 7 | 8 | .bic { 9 | font-family: "bic"; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .bic-database-fill-pencil:before { 17 | content: "\e614"; 18 | } 19 | 20 | .bic-database-fill-eye:before { 21 | content: "\e615"; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/renderer/styles/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/kodo-browser/63e1a24d7d53337daaf8043ef0bc5b1619c6bd20/src/renderer/styles/iconfont/iconfont.woff -------------------------------------------------------------------------------- /src/renderer/styles/iconfont/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/kodo-browser/63e1a24d7d53337daaf8043ef0bc5b1619c6bd20/src/renderer/styles/iconfont/iconfont.woff2 -------------------------------------------------------------------------------- /src/renderer/styles/index.scss: -------------------------------------------------------------------------------- 1 | // Global styles 2 | @import "./reset"; 3 | @import "./bootstrap"; 4 | @import "./hot-toast"; 5 | @import "./animations"; 6 | @import "./base-table"; 7 | @import "./utils"; 8 | @import "./iconfont/iconfont.css"; 9 | -------------------------------------------------------------------------------- /src/renderer/styles/reset.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100vh; 3 | width: 100vw; 4 | } 5 | 6 | body { 7 | overflow: hidden; 8 | } 9 | 10 | :focus { 11 | outline: none; 12 | } 13 | 14 | *:disabled { 15 | cursor: not-allowed; 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/styles/utils/box.scss: -------------------------------------------------------------------------------- 1 | .w-10 { 2 | width: 10% !important; 3 | } 4 | 5 | .w-15 { 6 | width: 15% !important; 7 | } 8 | 9 | .mxw-25 { 10 | max-width: 25% !important; 11 | } 12 | 13 | .mxw-50 { 14 | max-width: 50% !important; 15 | } 16 | 17 | .mnw-50 { 18 | min-width: 50% !important; 19 | } 20 | 21 | .h-50v { 22 | height: 50vh !important; 23 | } 24 | 25 | .h-60v { 26 | height: 60vh !important; 27 | } 28 | 29 | .mxh-50v { 30 | max-height: 50vh !important; 31 | } 32 | 33 | .mxh-60v { 34 | max-height: 60vh !important; 35 | } 36 | 37 | .transition-box-size { 38 | transition-property: margin, padding, width, height, border; 39 | transition-duration: .3s; 40 | transition-timing-function: ease; 41 | } 42 | 43 | .box-fit-contain { 44 | max-width: 100%; 45 | max-height: 100%; 46 | object-fit: contain; 47 | } 48 | 49 | .clickable { 50 | --clickable-focus-shadow-rgb: var(--bs-primary-rgb); 51 | --clickable-focus-box-shadow: 0 0 0 0.25rem rgba(var(--clickable-focus-shadow-rgb), .5); 52 | cursor: pointer; 53 | 54 | &:focus { 55 | box-shadow: var(--clickable-focus-box-shadow); 56 | } 57 | } 58 | 59 | .d-contents { 60 | display: contents; 61 | } 62 | 63 | 64 | .invisible-no-h { 65 | visibility: hidden; 66 | height: 0 !important; 67 | padding: 0 !important; 68 | margin: 0 !important; 69 | } 70 | 71 | .invisible-no-w { 72 | visibility: hidden; 73 | width: 0 !important; 74 | padding: 0 !important; 75 | margin: 0 !important; 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/styles/utils/css-grid.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a css grid layout util with 12 columns 3 | */ 4 | 5 | @mixin gen-grid-span-cols( 6 | $range, 7 | ) { 8 | @for $start from 1 through $range { 9 | @for $span from 1 through $range - $start + 1 { 10 | .grid-col-#{$start}-s#{$span} { 11 | grid-column: #{$start} / span #{$span}; 12 | } 13 | } 14 | } 15 | } 16 | 17 | @mixin gen-grid-fill-rest-cols( 18 | $range, 19 | ) { 20 | @for $start from 1 through $range { 21 | .grid-col-#{$start}-fill-rest { 22 | grid-column: #{$start} / -1; 23 | } 24 | } 25 | } 26 | 27 | .grid-auto { 28 | --grid-gap: 1rem; 29 | display: grid; 30 | gap: var(--grid-gap); 31 | 32 | @include gen-grid-span-cols(12); 33 | 34 | @include gen-grid-fill-rest-cols(12); 35 | } 36 | 37 | .grid-auto.grid-form { 38 | --label-min-width: 4rem; 39 | --label-max-width: 12rem; 40 | align-items: baseline; 41 | 42 | /** 43 | * only one label per row 44 | */ 45 | &.label-col-1 { 46 | grid-template-columns: 47 | fit-content(var(--label-max-width)) repeat(11, 1fr); 48 | 49 | label { 50 | overflow-wrap: break-word; 51 | word-break: break-word; 52 | grid-column: 1 / span 1 53 | } 54 | label + * { 55 | grid-column: 2 / span 11 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/styles/utils/index.scss: -------------------------------------------------------------------------------- 1 | @import "./box.scss"; 2 | @import "./css-grid.scss"; 3 | @import "./text.scss"; 4 | @import "./scroll.scss"; 5 | -------------------------------------------------------------------------------- /src/renderer/styles/utils/scroll.scss: -------------------------------------------------------------------------------- 1 | .scroll-max-vh-60 { 2 | max-height: 60vh; 3 | overflow-y: auto; 4 | overflow-x: hidden; 5 | } 6 | 7 | .scroll-max-vh-50 { 8 | max-height: 50vh; 9 | overflow-y: auto; 10 | overflow-x: hidden; 11 | } 12 | 13 | .scroll-max-vh-40 { 14 | max-height: 40vh; 15 | overflow-y: auto; 16 | overflow-x: hidden; 17 | } 18 | 19 | .scroll-shadow { 20 | background: 21 | /* Shadow covers */ 22 | linear-gradient(white 30%, rgba(255,255,255,0)), 23 | linear-gradient(rgba(255,255,255,0), white 70%) 0 100%, 24 | /* Shadows */ 25 | radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), 26 | radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; 27 | background-repeat: no-repeat; 28 | background-color: white; 29 | background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; 30 | background-attachment: local, local, scroll, scroll; 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/styles/utils/text.scss: -------------------------------------------------------------------------------- 1 | .no-selectable { 2 | user-select: none; 3 | } 4 | 5 | .text-link { 6 | color: var(--bs-link-color); 7 | cursor: pointer; 8 | 9 | &:hover, &:focus { 10 | color: var(--bs-link-hover-color); 11 | text-decoration: underline; 12 | } 13 | &.disable { 14 | color: var(--bs-gray-600); 15 | } 16 | } 17 | 18 | @each $color, $_ in $colors { 19 | .text-#{$color} { 20 | color: var(--bs-#{$color}); 21 | } 22 | } 23 | 24 | .text-brown { 25 | color: #8a6d3b; 26 | } 27 | 28 | .text-slate { 29 | color: #748b89 30 | } 31 | 32 | .overflow-ellipsis { 33 | --line-num: 1; 34 | display: -webkit-box; 35 | overflow: hidden; 36 | word-break: break-all; 37 | -webkit-box-orient: vertical; 38 | -webkit-line-clamp: var(--line-num); 39 | } 40 | 41 | .overflow-ellipsis-inline { 42 | @extend .overflow-ellipsis; 43 | display: -webkit-inline-box; 44 | } 45 | 46 | .overflow-ellipsis-one-line { 47 | display: inline-block; 48 | text-overflow: ellipsis; 49 | white-space: nowrap; 50 | overflow: hidden; 51 | word-break: break-all; 52 | vertical-align: bottom; 53 | } 54 | 55 | .text-pre-line { 56 | white-space: pre-line; 57 | } 58 | 59 | .text-break-all { 60 | word-break: break-all; 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "resolveJsonModule": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "dom", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "src", 24 | "paths": { 25 | "@main/*": [ 26 | "main/*" 27 | ], 28 | "@renderer/*": [ 29 | "renderer/*" 30 | ], 31 | "@common/*": [ 32 | "common/*" 33 | ] 34 | }, 35 | "esModuleInterop": true, 36 | "forceConsistentCasingInFileNames": true, 37 | "sourceMap": true, 38 | "jsx": "react" 39 | }, 40 | "exclude": [ 41 | "node_modules", 42 | "dist", 43 | "build", 44 | "webpack", 45 | "src/renderer/static", 46 | "./*.js" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /webpack/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | const projectRoot = fs.realpathSync(process.cwd()) 7 | const resolveApp = relativePath => path.resolve(projectRoot, relativePath) 8 | 9 | const buildPath = process.env.BUILD_PATH || 'dist'; 10 | 11 | const pages = [ 12 | 'src/renderer/index.ejs', 13 | ] 14 | const copies = [ 15 | 'src/renderer/static', 16 | ] 17 | 18 | module.exports = { 19 | appPath: resolveApp('.'), 20 | appBuild: resolveApp(buildPath), 21 | appPages: pages.map(resolveApp), 22 | appPackageJson: resolveApp('package.json'), 23 | appSrc: resolveApp('src'), 24 | appCommon: resolveApp('src/common'), 25 | appNodeModules: resolveApp('node_modules'), 26 | appWebpackCache: resolveApp('node_modules/.cache'), 27 | 28 | appMain: resolveApp('src/main'), 29 | appMainIndex: resolveApp('src/main/index.ts'), 30 | appMainDownloader: resolveApp('src/main/download-worker.ts'), 31 | appMainUploader: resolveApp('src/main/upload-worker.ts'), 32 | appBuildMain: resolveApp('dist/main'), 33 | 34 | appRenderer: resolveApp('src/renderer'), 35 | appBuildRenderer: resolveApp('dist/renderer'), 36 | appRendererIndex: resolveApp('src/renderer/index.tsx'), 37 | appRendererComponents: resolveApp('src/renderer/components'), 38 | appRendererCopies: copies.map(resolveApp) 39 | } 40 | -------------------------------------------------------------------------------- /webpack/webpack-main.config.js: -------------------------------------------------------------------------------- 1 | // main config 2 | const paths = require("./paths"); 3 | 4 | module.exports = function(webpackEnv) { 5 | const isEnvDevelopment = webpackEnv.development; 6 | const isEnvProduction = webpackEnv.production; 7 | const shouldUseSourceMap = webpackEnv.sourcemap; 8 | 9 | return { 10 | devtool: isEnvProduction 11 | ? shouldUseSourceMap 12 | ? "source-map" 13 | : false 14 | : isEnvDevelopment && "source-map", 15 | target: "electron-main", 16 | mode: isEnvProduction ? "production" : isEnvDevelopment && "development", 17 | resolve: { 18 | alias: { 19 | "@common": paths.appCommon, 20 | "@main": paths.appMain, 21 | }, 22 | extensions: [".ts", ".js"], 23 | }, 24 | entry: { 25 | main: paths.appMainIndex, 26 | downloader: paths.appMainDownloader, 27 | uploader: paths.appMainUploader, 28 | }, 29 | output: { 30 | filename: "[name]-bundle.js", 31 | chunkFilename: isEnvProduction 32 | ? "[name].chunk.[chunkhash:8].js" 33 | : isEnvDevelopment && "[name].chunk.js", 34 | path: paths.appBuildMain, 35 | clean: true, 36 | }, 37 | module: { 38 | rules: [ 39 | { 40 | test: /\.js$/, 41 | enforce: "pre", 42 | use: ["source-map-loader"], 43 | }, 44 | { test: /\.ts$/, loader: "ts-loader" }, 45 | ], 46 | }, 47 | optimization: { 48 | splitChunks: { 49 | chunks: "all", 50 | cacheGroups: { 51 | libs: { 52 | name: "chunk-libs", 53 | test: /[\\/]node_modules[\\/]/, 54 | priority: 20, 55 | chunks: "initial", // only package third parties that are initially dependent 56 | }, 57 | }, 58 | }, 59 | minimize: isEnvProduction, 60 | }, 61 | ignoreWarnings: [ 62 | /Failed to parse source map/, 63 | ], 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const mainConfig = require('./webpack-main.config') 2 | const rendererConfig = require('./webpack-renderer.config') 3 | 4 | module.exports = [ 5 | mainConfig, 6 | rendererConfig, 7 | ] 8 | -------------------------------------------------------------------------------- /zip.js: -------------------------------------------------------------------------------- 1 | // require modules 2 | var fs = require('fs'); 3 | var archiver = require('archiver'); 4 | var sh = require('shelljs'); 5 | 6 | if (process.argv.length > 3) { 7 | var dest = process.argv[2].trim(); 8 | var src = process.argv[3].trim(); 9 | if (process.platform != 'win32') sh.exec(`zip ${dest} -r ${src}`, function (code, stdout, stderr) { 10 | if (stderr) console.log(stderr); 11 | }); 12 | else { 13 | if (src.indexOf('darwin') != -1) { 14 | console.log('can not zip *.app for mac os in windows, you should zip it manually! Location is ' + dest); 15 | } else zip(src, dest); 16 | } 17 | } 18 | 19 | /** 20 | * @param src 'subdir/' 21 | * @param dest 'a.zip' 22 | */ 23 | function zip(src, dest) { 24 | return new Promise((a, b) => { 25 | // create a file to stream archive data to. 26 | var output = fs.createWriteStream(dest); //__dirname + '/example.zip' 27 | var archive = archiver('zip', { 28 | zlib: { 29 | level: 9 30 | } // Sets the compression level. 31 | }); 32 | 33 | // listen for all archive data to be written 34 | // 'close' event is fired only when a file descriptor is involved 35 | output.on('close', function () { 36 | console.info(archive.pointer() + ' total bytes'); 37 | console.info('archiver has been finalized and the output file descriptor has closed.'); 38 | a(); 39 | }); 40 | 41 | // This event is fired when the data source is drained no matter what was the data source. 42 | // It is not part of this library but rather from the NodeJS Stream API. 43 | // @see: https://nodejs.org/api/stream.html#stream_event_end 44 | output.on('end', function () { 45 | console.info('Data has been drained'); 46 | }); 47 | 48 | // good practice to catch warnings (ie stat failures and other non-blocking errors) 49 | archive.on('warning', function (err) { 50 | if (err.code === 'ENOENT') { 51 | // log warning 52 | console.warn(err); 53 | } else { 54 | // throw error 55 | b(err); 56 | } 57 | }); 58 | 59 | // good practice to catch this error explicitly 60 | archive.on('error', function (err) { 61 | b(err); 62 | }); 63 | 64 | // pipe archive data to the file 65 | archive.pipe(output); 66 | 67 | // append files from a glob pattern 68 | archive.directory(src); 69 | 70 | // finalize the archive (ie we are done appending files but streams have to finish yet) 71 | // 'close', 'end' or 'finish' may be fired right after calling this method so register to them beforehand 72 | archive.finalize(); 73 | }); 74 | } --------------------------------------------------------------------------------