├── .github ├── changelog-configuration.json ├── release.yml └── workflows │ ├── nightly.yml │ └── publish.yml ├── .gitignore ├── .imgbotconfig ├── .scripts ├── before-build.js └── mirror-latest-json.js ├── CHANGELOG.md ├── LICENCE ├── README.en-US.md ├── README.md ├── biome.json ├── bunfig.toml ├── docs ├── CONTRIBUTION.md └── images │ ├── dark │ ├── 1-home.avif │ ├── 10-mobile-download-list.avif │ ├── 2-receive-qrcode.avif │ ├── 3-mobile-send-index.avif │ ├── 4-pc-receive-empty.avif │ ├── 5-mobile-uploading.avif │ ├── 6-pc-receiving.avif │ ├── 7-wait-selecting.avif │ ├── 8-selected.avif │ └── 9-send-qrcode.avif │ └── light │ ├── 1-home.avif │ ├── 2-receive-qrcode.avif │ ├── 4-pc-receive-empty.avif │ ├── 5-mobile-uploading.avif │ ├── 6-pc-receiving.avif │ ├── 7-wait-selecting.avif │ ├── 8-selected.avif │ ├── 9-send-qrcode.avif │ ├── mobile-download-list.avif │ └── mobile-send-index.avif ├── index.html ├── package.json ├── public ├── icon.png └── logo.png ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── linux │ └── alley.desktop ├── src │ ├── error.rs │ ├── i18n │ │ ├── en_us.rs │ │ ├── mod.rs │ │ └── zh_cn.rs │ ├── lazy.rs │ ├── linux.rs │ ├── main.rs │ ├── menu.rs │ ├── server │ │ ├── error.rs │ │ ├── logger.rs │ │ └── mod.rs │ └── stream.rs └── tauri.conf.json ├── src ├── App.scss ├── App.tsx ├── about.scss ├── about.tsx ├── advance │ └── index.tsx ├── api.ts ├── assets │ └── react.svg ├── components │ ├── aboutButton │ │ └── index.tsx │ ├── file-type-icon │ │ └── index.tsx │ └── qrcode │ │ ├── index.scss │ │ └── index.tsx ├── context.ts ├── index.scss ├── index.tsx ├── lazy.ts ├── pages │ ├── receive │ │ ├── fileListItem.tsx │ │ ├── fileType.ts │ │ ├── header.tsx │ │ ├── index.scss │ │ └── index.tsx │ └── send │ │ ├── index.scss │ │ ├── index.tsx │ │ └── utils.ts └── vite-env.d.ts ├── static ├── .gitignore ├── README.md ├── bunfig.toml ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public │ └── logo.png ├── src │ ├── App.scss │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── button │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── card │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── divider │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── error-block │ │ │ ├── icons │ │ │ │ └── empty.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── grid │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ └── item.tsx │ │ ├── link │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── list │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── loading │ │ │ ├── dot │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── spin │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ ├── progress │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── result │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── space │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── switch │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── toast │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── upload │ │ │ ├── asyncPool.ts │ │ │ ├── fileItem.tsx │ │ │ ├── fileSize.ts │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ ├── request.ts │ │ │ └── util.ts │ │ └── utils │ │ │ └── index.ts │ ├── context.ts │ ├── i18n │ │ ├── en_us.ts │ │ ├── index.ts │ │ └── zh_cn.ts │ ├── index.scss │ ├── index.tsx │ ├── lazy.ts │ ├── pages │ │ ├── receive │ │ │ ├── fileType.ts │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ └── send │ │ │ ├── index.scss │ │ │ └── index.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── types │ ├── index.d.ts │ ├── jsx.d.ts │ └── upload.d.ts └── vite.config.ts ├── tsconfig.json ├── tsconfig.node.json ├── types ├── context.d.ts └── index.d.ts └── vite.config.ts /.github/changelog-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | { 4 | "title": "## 🎉 Features", 5 | "labels": [ 6 | "feature" 7 | ] 8 | }, 9 | { 10 | "title": "## 🚀 Enhancements", 11 | "labels": [ 12 | "enhancement" 13 | ] 14 | }, 15 | { 16 | "title": "## 🐛 Fixes", 17 | "labels": [ 18 | "fix", 19 | "bug" 20 | ] 21 | }, 22 | { 23 | "title": "## 📦 Dependencies", 24 | "labels": [ 25 | "dependencies" 26 | ] 27 | }, 28 | { 29 | "title": "## 🛠 Breaking Changes", 30 | "labels": [ 31 | "semver-major", 32 | "breaking-change" 33 | ] 34 | } 35 | ], 36 | "pr_template": "- #{{TITLE}}(##{{NUMBER}})", 37 | "base_branches": [ 38 | "main" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | - documentation 6 | authors: 7 | - imgbot 8 | categories: 9 | - title: Breaking Changes 🛠 10 | labels: 11 | - semver-major 12 | - breaking-change 13 | - title: Exciting New Features 🎉 14 | labels: 15 | - semver-minor 16 | - enhancement 17 | - title: Fixed 18 | labels: 19 | - bug 20 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: "nightly" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | check-changed-paths: 9 | runs-on: ubuntu-22.04 10 | outputs: 11 | changed: ${{ steps.changed-front.outputs.changed }} 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 100 17 | 18 | - uses: marceloprado/has-changed-path@v1.0.1 19 | id: changed-front 20 | with: 21 | paths: public src src-tauri static package.json .scripts .github 22 | 23 | create-release: 24 | needs: check-changed-paths 25 | if: needs.check-changed-paths.outputs.changed == 'true' 26 | permissions: 27 | contents: write 28 | runs-on: ubuntu-22.04 29 | outputs: 30 | release_id: ${{ steps.create-release.outputs.result }} 31 | package_version: ${{ env.PACKAGE_VERSION }} 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: setup node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 20 40 | 41 | - name: get version 42 | run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV 43 | 44 | - name: check latest version 45 | uses: actions/github-script@v7 46 | id: latest-version 47 | with: 48 | script: | 49 | const { data } = await github.request( 50 | 'GET /repos/{owner}/{repo}/releases/latest', 51 | { 52 | owner: context.repo.owner, 53 | repo: context.repo.repo, 54 | headers: { 55 | 'X-GitHub-Api-Version': '2022-11-28' 56 | } 57 | } 58 | ) 59 | 60 | const latesVersion = data.tag_name.slice(1) 61 | 62 | if (latesVersion === process.env.PACKAGE_VERSION) throw new Error("当前要发布的版本号与 latest 版本号相同") 63 | 64 | return latesVersion 65 | 66 | - name: get old nightly release id 67 | run: | 68 | release_id=$(curl -s 'https://api.github.com/repos/alley-rs/fluxy/releases/tags/nightly' | awk -F'[{},:]+' '/^ "id"/ {print $2}' | xargs) 69 | echo "RELEASE_ID=$release_id" >> $GITHUB_ENV 70 | 71 | # - name: delete old nightly release 72 | # uses: actions/github-script@v7 73 | # if: env.RELEASE_ID != '' 74 | # with: 75 | # script: | 76 | # await github.rest.repos.deleteRelease({ 77 | # owner: context.repo.owner, 78 | # repo: context.repo.repo, 79 | # release_id: process.env.RELEASE_ID, 80 | # }) 81 | 82 | - name: delete old nightly release 83 | if: env.RELEASE_ID != '' 84 | run: gh release delete nightly --cleanup-tag 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | 88 | - name: create release 89 | id: create-release 90 | uses: actions/github-script@v7 91 | with: 92 | script: | 93 | const { data } = await github.rest.repos.createRelease({ 94 | owner: context.repo.owner, 95 | repo: context.repo.repo, 96 | tag_name: "nightly", 97 | name: "Nightly build", 98 | draft: true, 99 | prerelease: true, 100 | }) 101 | return data.id 102 | 103 | build-tauri: 104 | needs: create-release 105 | permissions: 106 | contents: write 107 | strategy: 108 | fail-fast: false 109 | matrix: 110 | include: 111 | - platform: "macos-latest" 112 | args: "--target aarch64-apple-darwin" 113 | - platform: "macos-latest" 114 | args: "--target x86_64-apple-darwin" 115 | - platform: "ubuntu-22.04" 116 | args: "" 117 | - platform: "windows-latest" 118 | args: "" 119 | 120 | runs-on: ${{ matrix.platform }} 121 | steps: 122 | - uses: actions/checkout@v4 123 | 124 | - name: setup node 125 | uses: actions/setup-node@v4 126 | with: 127 | node-version: 20 128 | 129 | - uses: oven-sh/setup-bun@v1 130 | 131 | - name: install Rust stable 132 | uses: dtolnay/rust-toolchain@stable 133 | with: 134 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 135 | 136 | - name: install dependencies (ubuntu only) 137 | if: matrix.platform == 'ubuntu-22.04' 138 | run: | 139 | sudo apt-get update 140 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 141 | 142 | - name: install frontend dependencies 143 | run: bun run install:all 144 | 145 | - uses: tauri-apps/tauri-action@v0 146 | env: 147 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 148 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 149 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 150 | with: 151 | releaseId: ${{ needs.create-release.outputs.release_id }} 152 | releaseBody: ${{ needs.create-release.outputs.changelog }} 153 | updaterJsonPreferNsis: true 154 | args: ${{ matrix.args }} 155 | 156 | publish-release: 157 | permissions: 158 | contents: write 159 | runs-on: ubuntu-22.04 160 | needs: [create-release, build-tauri] 161 | 162 | steps: 163 | - name: publish release 164 | id: publish-release 165 | uses: actions/github-script@v7 166 | env: 167 | release_id: ${{ needs.create-release.outputs.release_id }} 168 | PACKAGE_VERSION: ${{ needs.create-release.outputs.package_version }} 169 | LAST_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} 170 | with: 171 | script: | 172 | github.rest.repos.updateRelease({ 173 | owner: context.repo.owner, 174 | repo: context.repo.repo, 175 | release_id: parseInt(process.env.release_id), 176 | body: `> [!WARNING]\n> 每夜版可能无法自动升级。/The nightly version may not auto-update.\n\n## Last Commit\n\n${process.env.LAST_COMMIT_MESSAGE}`, 177 | draft: false 178 | }) 179 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | on: 3 | push: 4 | branches: 5 | - release 6 | 7 | jobs: 8 | create-release: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-22.04 12 | outputs: 13 | release_id: ${{ steps.create-release.outputs.result }} 14 | package_version: ${{ env.PACKAGE_VERSION }} 15 | latest_version: ${{ steps.latest-version.outputs.result }} 16 | changelog: ${{ steps.github_release_changelog.outputs.changelog }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | 26 | - name: get version 27 | run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV 28 | 29 | - name: check latest version 30 | uses: actions/github-script@v7 31 | id: latest-version 32 | with: 33 | script: | 34 | const { data } = await github.request( 35 | 'GET /repos/{owner}/{repo}/releases/latest', 36 | { 37 | owner: context.repo.owner, 38 | repo: context.repo.repo, 39 | headers: { 40 | 'X-GitHub-Api-Version': '2022-11-28' 41 | } 42 | } 43 | ) 44 | 45 | const latesVersion = data.tag_name.slice(1) 46 | 47 | if (latesVersion === process.env.PACKAGE_VERSION) throw new Error("当前要发布的版本号与 latest 版本号相同") 48 | 49 | return latesVersion 50 | 51 | - name: build changelog 52 | id: github_release_changelog 53 | uses: mikepenz/release-changelog-builder-action@v3 54 | with: 55 | configuration: ".github/changelog-configuration.json" 56 | toTag: "refs/heads/main" 57 | failOnError: true 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: create release 62 | id: create-release 63 | uses: actions/github-script@v7 64 | env: 65 | changelog: ${{ steps.github_release_changelog.outputs.changelog }} 66 | with: 67 | script: | 68 | const { data } = await github.rest.repos.createRelease({ 69 | owner: context.repo.owner, 70 | repo: context.repo.repo, 71 | tag_name: `v${process.env.PACKAGE_VERSION}`, 72 | name: `v${process.env.PACKAGE_VERSION}`, 73 | body: process.env.changelog, 74 | draft: true, 75 | prerelease: process.env.PACKAGE_VERSION.indexOf('alpha') >= 0, 76 | }) 77 | return data.id 78 | 79 | build-tauri: 80 | needs: create-release 81 | permissions: 82 | contents: write 83 | strategy: 84 | fail-fast: false 85 | matrix: 86 | include: 87 | - platform: "macos-latest" 88 | args: "--target aarch64-apple-darwin" 89 | - platform: "macos-latest" 90 | args: "--target x86_64-apple-darwin" 91 | - platform: "ubuntu-22.04" 92 | args: "" 93 | - platform: "windows-latest" 94 | args: "" 95 | 96 | runs-on: ${{ matrix.platform }} 97 | steps: 98 | - uses: actions/checkout@v4 99 | 100 | - name: setup node 101 | uses: actions/setup-node@v4 102 | with: 103 | node-version: lts/* 104 | 105 | - uses: oven-sh/setup-bun@v1 106 | 107 | - name: install Rust stable 108 | uses: dtolnay/rust-toolchain@stable 109 | with: 110 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 111 | 112 | - name: install dependencies (ubuntu only) 113 | if: matrix.platform == 'ubuntu-22.04' 114 | run: | 115 | sudo apt-get update 116 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 117 | 118 | - name: install frontend dependencies 119 | run: bun run install:all 120 | 121 | - uses: tauri-apps/tauri-action@v0 122 | env: 123 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 124 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 125 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 126 | with: 127 | releaseId: ${{ needs.create-release.outputs.release_id }} 128 | releaseBody: ${{ needs.create-release.outputs.changelog }} 129 | updaterJsonPreferNsis: true 130 | args: ${{ matrix.args }} 131 | 132 | publish-release: 133 | permissions: 134 | contents: write 135 | runs-on: ubuntu-22.04 136 | needs: [create-release, build-tauri] 137 | 138 | steps: 139 | - name: publish release 140 | id: publish-release 141 | uses: actions/github-script@v7 142 | env: 143 | release_id: ${{ needs.create-release.outputs.release_id }} 144 | PACKAGE_VERSION: ${{ needs.create-release.outputs.package_version }} 145 | with: 146 | script: | 147 | github.rest.repos.updateRelease({ 148 | owner: context.repo.owner, 149 | repo: context.repo.repo, 150 | release_id: process.env.release_id, 151 | draft: false, 152 | }) 153 | 154 | upload_mirror_json: 155 | permissions: 156 | contents: write 157 | runs-on: ubuntu-22.04 158 | needs: [create-release, publish-release] 159 | env: 160 | release_id: ${{ needs.create-release.outputs.release_id }} 161 | 162 | steps: 163 | - uses: actions/checkout@v4 164 | 165 | - name: get latest.json 166 | id: get-latest 167 | uses: actions/github-script@v7 168 | with: 169 | script: | 170 | const { owner, repo } = context.repo; 171 | const assets = await github.rest.repos.listReleaseAssets({ 172 | owner: owner, 173 | repo: repo, 174 | release_id: process.env.release_id, 175 | per_page: 50, 176 | }); 177 | const asset = assets.data.find((e) => e.name === 'latest.json'); 178 | 179 | if (!asset) throw new Error("latest.json was not found in release assets"); 180 | 181 | const data = await github.request( 182 | "GET /repos/{owner}/{repo}/releases/assets/{asset_id}", 183 | { 184 | owner: owner, 185 | repo: repo, 186 | asset_id: asset.id, 187 | headers: { 188 | accept: "application/octet-stream", 189 | }, 190 | }, 191 | ); 192 | 193 | return Buffer.from(data.data).toString() 194 | 195 | - name: new mirror latest.json 196 | env: 197 | TEXT: ${{ steps.get-latest.outputs.result }} 198 | run: | 199 | cd .scripts 200 | node mirror-latest-json.js 201 | 202 | - uses: wlixcc/SFTP-Deploy-Action@v1.2.4 203 | with: 204 | username: thepoy 205 | server: thepoy.cc 206 | password: ${{ secrets.TAURI_KEY_PASSWORD }} 207 | local_path: ./.scripts/mirrors/* 208 | remote_path: /home/thepoy/app/alley-transfer 209 | sftpArgs: "-o ConnectTimeout=5" 210 | 211 | delete-nightly-release-and-tag: 212 | permissions: 213 | contents: write 214 | runs-on: ubuntu-22.04 215 | needs: upload_mirror_json 216 | 217 | steps: 218 | - uses: actions/checkout@v4 219 | 220 | - name: get old nightly release id 221 | run: | 222 | release_id=$(curl -s 'https://api.github.com/repos/alley-rs/lsar/releases/tags/nightly' | awk -F'[{},:]+' '/^ "id"/ {print $2}' | xargs) 223 | echo "RELEASE_ID=$release_id" >> $GITHUB_ENV 224 | 225 | - name: delete old nightly release 226 | if: env.RELEASE_ID != '' 227 | run: gh release delete nightly --cleanup-tag 228 | env: 229 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 230 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | src-tauri/static 26 | bun.lockb 27 | -------------------------------------------------------------------------------- /.imgbotconfig: -------------------------------------------------------------------------------- 1 | { 2 | "schedule": "weekly", 3 | "ignoredFiles": [ 4 | "src-tauri/icons/*" 5 | ], 6 | "prTitle": "chore: [ImgBot] Optimize images" 7 | } 8 | -------------------------------------------------------------------------------- /.scripts/before-build.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | const updateVersion = () => { 4 | const infoBuffer = fs.readFileSync("../package.json"); 5 | const info = JSON.parse(infoBuffer.toString()); 6 | 7 | const { version } = info; 8 | 9 | if (process.platform !== "win32") { 10 | return version; 11 | } 12 | 13 | if (version.indexOf("-") === -1) { 14 | return; 15 | } 16 | 17 | const newVersion = version.split("-")[0]; 18 | info.version = newVersion; 19 | 20 | fs.writeFileSync("../package.json", JSON.stringify(info)); 21 | 22 | return newVersion; 23 | }; 24 | 25 | console.log(updateVersion()); 26 | -------------------------------------------------------------------------------- /.scripts/mirror-latest-json.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | const mirrors = [ 5 | { host: "mirror.ghproxy.com", prefix: true }, 6 | { host: "kkgithub.com" }, 7 | { host: "521github.com" }, 8 | { host: "hub.yzuu.cf" }, 9 | ]; 10 | 11 | const GITHUB = "https://github.com/"; 12 | 13 | const mirrorContent = (mirror, text) => { 14 | if (mirror.prefix) { 15 | return text.replaceAll( 16 | GITHUB, 17 | `https://${mirror.host}/https://github.com/`, 18 | ); 19 | } else { 20 | return text.replaceAll(GITHUB, `https://${mirror.host}/`); 21 | } 22 | }; 23 | 24 | const newMirrorJSON = (text, mirror, filepath) => { 25 | const content = mirrorContent(mirror, text); 26 | fs.writeFileSync(filepath, content); 27 | }; 28 | 29 | const run = async () => { 30 | let text = process.env.TEXT; 31 | 32 | // 删除开头和结尾的引号 33 | if (text[0] === '"') { 34 | text = text.slice(1); 35 | } 36 | 37 | if (text[text.length - 1] === '"') { 38 | text = text.slice(0, text.length - 1); 39 | } 40 | 41 | text = text 42 | .replace("\\n}", "}") // 处理结尾的换行 43 | .replaceAll("\\n ", "\n") // 删除 notes 外的换行 44 | .replaceAll(/\s{2,}/g, "") // 删除所有空白符 45 | .replaceAll('\\"', '"') // 替换转义的双引号 46 | .replaceAll("\\\\n", "\\n"); // 处理 notes 中的换行 47 | 48 | const currentDir = process.cwd(); 49 | const targetDir = path.join(currentDir, "mirrors"); 50 | 51 | if (!fs.existsSync(targetDir)) { 52 | fs.mkdirSync(targetDir); 53 | } 54 | 55 | mirrors.forEach((m, i) => 56 | newMirrorJSON(text, m, path.join(targetDir, `latest-mirror-${i + 1}.json`)), 57 | ); 58 | }; 59 | 60 | run(); 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 4 | 5 | ### features 6 | 7 | #### Common 8 | 9 | - 通过二维码或链接快速连接发送端和接收端 10 | - 页面根据系统主题自动切换暗色模式 11 | - 正式版支持自动检测更新 12 | - 中国大陆用户通过镜像链接更新 13 | - 部分组件添加过渡动画 14 | 15 | #### Send 16 | 17 | - Mobile 18 | 19 | - 手机端上传时控制并发量,避免手机浏览器内存溢出 20 | - 固定并发量为 2 21 | - 手机端发送时显示实时进度和速度 22 | - 手机端上传时可中断未完成的上传任务 23 | 24 | - Desktop 25 | - 桌面端发送任务时通过文件服务器的方式与接收端传输文件 26 | 27 | #### Receive 28 | 29 | - Mobile 30 | 31 | - 因手机操作系统权限问题,手机端通过浏览器下载到默认目录 32 | 33 | - Desktop 34 | - 桌面端接收时显示实时进度和速度 35 | - 桌面端可配置接收文件的保存目录 36 | 37 | ### fix 38 | 39 | #### Common 40 | 41 | - deepin 上非整数倍缩放时窗口比例异常 [3df05a](https://github.com/alley-rs/alley-transfer/commit/ceaaa7bec019e50aad3486c9a4054ed6223df05a) 42 | -------------------------------------------------------------------------------- /README.en-US.md: -------------------------------------------------------------------------------- 1 | English | [简体中文](./README.md) 2 | 3 |

4 | 5 |
6 | GitHub releases 7 |

8 | 9 | # FLUXY 10 | 11 | FLUXY is a fast file transfer tool for local area networks, supporting Windows, macOS, and Linux. It aims to provide a smooth file exchange experience, particularly for frequent file transfers between mobile phones and computers. 12 | 13 | Before developing the mobile app, files could only be uploaded and received through the mobile browser. To improve the upload experience, it is recommended to use [Edge](https://play.google.com/store/search?q=edge&c=apps), [Chrome](https://play.google.com/store/search?q=Chrome&c=apps), [Firefox](https://play.google.com/store/apps/details?id=org.mozilla.firefox), or [QQ Browser](https://browser.qq.com/mobile). 14 | 15 | ## Features 16 | 17 | The key features that distinguish this software from other similar tools: 18 | 19 | - Open-source 20 | - Small in size 21 | 22 | | Platform and Format | Size (v0.1.0-alpha.7) | 23 | | ---------------------------------------- | --------------------- | 24 | | macOS aarch64 - dmg | 4.29 MB | 25 | | Linux (Debian/Ubuntu/Deepin) amd64 - deb | 6.45 MB | 26 | | Windows amd64 - msi | 4.29 MB | 27 | | Windows amd64 - exe | 4.07 MB | 28 | 29 | ## Usage 30 | 31 | After launching FLUXY, please select the transfer mode: 32 | 33 | | Light Mode | Dark Mode | 34 | | ----------------------------------------------------------- | ---------------------------------------------------------- | 35 | | ![Transfer Mode Selection](./docs/images/light/1-home.avif) | ![Transfer Mode Selection](./docs/images/dark/1-home.avif) | 36 | 37 | ### Receive Mode 38 | 39 | The PC will display a QR code for the mobile phone to scan. 40 | 41 | On the mobile device, click the "Select File" button at the bottom of the page to upload multiple files, and the PC will also be able to see the progress of the file reception. 42 | 43 | > Click the images to view them in full size. 44 | 45 | | | PC Before Scanning | Mobile | PC After Scanning | Mobile Uploading | PC Receiving | 46 | | ----- | ------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- | 47 | | Light | ![Receive QR Code](./docs/images/light/2-receive-qrcode.avif) | ![Mobile Send Index](./docs/images/light/mobile-send-index.avif) | ![PC Receive Empty](./docs/images/light/4-pc-receive-empty.avif) | ![Mobile Uploading](./docs/images/light/5-mobile-uploading.avif) | ![PC Receiving](./docs/images/light/6-pc-receiving.avif) | 48 | | Dark | ![Receive QR Code](./docs/images/dark/2-receive-qrcode.avif) | ![Mobile Send Index](./docs/images/dark/3-mobile-send-index.avif) | ![PC Receive Empty](./docs/images/dark/4-pc-receive-empty.avif) | ![Mobile Uploading](./docs/images/dark/5-mobile-uploading.avif) | ![PC Receiving](./docs/images/dark/6-pc-receiving.avif) | 49 | 50 | The default save path is `~/Downloads/alley`, which can be modified. 51 | 52 | ### Send Mode 53 | 54 | After selecting the send mode, you can drag the files to be sent into the software window through the file manager, and then click the confirm button to generate a QR code. Scan the QR code with your mobile device to open the list of files sent from the PC, and click the file name to save the file to your mobile phone. 55 | 56 | _Due to limitations of mobile operating systems, mobile browsers cannot implement batch downloads, and can only download files one by one._ 57 | 58 | > Click the images to view them in full size. 59 | 60 | | | PC Waiting for Selection | PC File List | PC Send QR Code | Mobile Receive Page | 61 | | ----- | ---------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------ | 62 | | Light | ![Wait for Selection](./docs/images/light/7-wait-selecting.avif) | ![Selected](./docs/images/light/8-selected.avif) | ![Send QR Code](./docs/images/light/9-send-qrcode.avif) | ![Mobile Download List](./docs/images/light/mobile-download-list.avif) | 63 | | Dark | ![Wait for Selection](./docs/images/dark/7-wait-selecting.avif) | ![Selected](./docs/images/dark/8-selected.avif) | ![Send QR Code](./docs/images/dark/9-send-qrcode.avif) | ![Mobile Download List](./docs/images/dark/10-mobile-download-list.avif) | 64 | 65 | ## Troubleshooting 66 | 67 | ### macOS "Damaged" Warning 68 | 69 | Because FLUXY is not signed by an Apple developer, there may be a system trust issue. You can force trust the program using the following command: 70 | 71 | ```bash 72 | sudo xattr -r -d com.apple.quarantine /Applications/fluxy.app 73 | ``` 74 | 75 | After closing the terminal, you should be able to open the program normally. 76 | 77 | ### Clearing Caches 78 | 79 | The frontend pages use the system WebView for rendering, and the cache files are also created by the system WebView. When the cache takes up a large amount of disk space, you can delete the cache directory using some cleanup tools or manually, without affecting the program's operation. 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [English](./README.en-US.md) | 简体中文 2 | 3 |

4 | 5 |
6 | GitHub releases 7 |

8 | 9 | # FLUXY 10 | 11 | FLUXY 是一款用于局域网内快速文件传输的工具,支持 Windows、macOS 和 Linux,旨在提供流畅的文件交换体验,尤其适用于手机与电脑间的频繁文件传输。 12 | 13 | 在未开发手机端前,只能通过手机浏览器上传和接收文件,为了更好的上传体验,建议使用 [Edge](https://play.google.com/store/search?q=edge&c=apps)、[Chrome](https://play.google.com/store/search?q=Chrome&c=apps) 、[Firefox](https://play.google.com/store/apps/details?id=org.mozilla.firefox) 或 [QQ浏览器](https://browser.qq.com/mobile)。 14 | 15 | ## 特点 16 | 17 | 本软件可能有别于其他同功能软件的特点有: 18 | 19 | - 开源 20 | 21 | - 体积小 22 | 23 | ## 使用 24 | 25 | 启动 FLUXY 后,请选择传输模式: 26 | 27 | | 亮色 | 暗色 | 28 | | ------------------------------------------------ | ----------------------------------------------- | 29 | | ![传输模式选择](./docs/images/light/1-home.avif) | ![传输模式选择](./docs/images/dark/1-home.avif) | 30 | 31 | ### 接收模式 32 | 33 | PC 端显示二维码供手机扫描。 34 | 35 | 在手机上点击页面最下面的的`选择文件`按钮即可上传多个文件,同时 PC 端也能看到收取文件的进度。 36 | 37 | > 点击图片可查看大图。 38 | 39 | | | PC 端扫描前 | 手机端 | PC 端扫描后 | 手机上传 | PC端接收 | 40 | | ---- | ------------------------------------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- | 41 | | 亮色 | ![receive-qrcode](./docs/images/light/2-receive-qrcode.avif) | ![mobile-send-index](./docs/images/light/mobile-send-index.avif) | ![pc-receive-empty](./docs/images/light/4-pc-receive-empty.avif) | ![mobile-uploading](./docs/images/light/5-mobile-uploading.avif) | ![pc-receiving](./docs/images/light/6-pc-receiving.avif) | 42 | | 暗色 | ![receive-qrcode](./docs/images/dark/2-receive-qrcode.avif) | ![mobile-send-index](./docs/images/dark/3-mobile-send-index.avif) | ![pc-receive-empty](./docs/images/dark/4-pc-receive-empty.avif) | ![mobile-uploading](./docs/images/dark/5-mobile-uploading.avif) | ![pc-receiving](./docs/images/dark/6-pc-receiving.avif) | 43 | 44 | 默认保存路径为 `~/Downloads/alley`,可以自行修改。 45 | 46 | ### 发送模式 47 | 48 | 选择发送模式后可通过文件管理器将要发送的文件拖入本软件窗口,之后点击确认按钮会出现一个二维码,使用手机扫描后会打开 PC 端发送的文件列表,点击文件名可将文件保存到手机。 49 | 50 | _受限于手机操作系统的限制,手机浏览器无法实现批量下载,只能逐个下载。_ 51 | 52 | > 点击图片可查看大图。 53 | 54 | | | PC 端待选文件 | PC 端待发文件列表 | PC 端发送二维码 | 手机端接收页 | 55 | | ---- | ------------------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------ | 56 | | 亮色 | ![wait-selecting](./docs/images/light/7-wait-selecting.avif) | ![selected](./docs/images/light/8-selected.avif) | ![send-qrcode](./docs/images/light/9-send-qrcode.avif) | ![mobile-download-list](./docs/images/light/mobile-download-list.avif) | 57 | | 暗色 | ![wait-selecting](./docs/images/dark/7-wait-selecting.avif) | ![selected](./docs/images/dark/8-selected.avif) | ![send-qrcode](./docs/images/dark/9-send-qrcode.avif) | ![mobile-download-list](./docs/images/dark/10-mobile-download-list.avif) | 58 | 59 | ## 常见问题 60 | 61 | ### macOS 提示已损坏 62 | 63 | 由于 FLUXY 未经过 Apple 开发者签名,可能会出现系统信任问题。您可以通过以下命令强制信任程序: 64 | 65 | ```bash 66 | sudo xattr -r -d com.apple.quarantine /Applications/fluxy.app 67 | ``` 68 | 69 | 关闭终端后就可以正常打开程序了。 70 | 71 | ### 缓存清理 72 | 73 | 前端页面使用系统 WebView 渲染,缓存文件同样也由系统 WebView 创建。 74 | 75 | 当缓存占据磁盘空间较大时,可以通过一些垃圾清理工具删除或手动删除缓存目录,不会影响程序的运行。 76 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.0/schema.json", 3 | "organizeImports": { 4 | "enabled": false 5 | }, 6 | "formatter": { 7 | "indentStyle": "space", 8 | "indentWidth": 2 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "correctness": { 15 | "useJsxKeyInIterable": "off" 16 | }, 17 | "style": { "noNonNullAssertion": "off" } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | registry = "https://registry.npmmirror.com" 3 | -------------------------------------------------------------------------------- /docs/CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | ## 前提 2 | 3 | 本软件由三部分组成: 4 | 5 | - 软件页面 - [SolidJS](https://www.solidjs.com) 6 | - 软件业务逻辑 - Rust 7 | - 客户端页面 - [SolidJS](https://www.solidjs.com) 8 | 9 | 对于想要贡献代码的朋友,前提条件是你至少要掌握 React 前端框架或 Rust 语言其中之一。而 React 本质上就是 typescript,所以如果你没接触过 React 但有 typescript 开发经验,只需要很短的时间便可以掌握 React 基础。 10 | 11 | 本软件的页面由类 React 框架 [SolidJS](https://www.solidjs.com) 开发,你如果掌握了 React,很快即可入门 [SolidJS](https://www.solidjs.com)。 12 | 13 | ## 项目组成部分 14 | 15 | ### SolidJS 16 | 17 | #### 软件页面 18 | 19 | 最初(0.3.0-alpha.9之前)为了快速开发,页面使用了 antd 组件库编写,但由于软件页面数量较少,使用 antd 时会打包一些额外的代码,所以在 0.3.0-alpha.9 之后我在不依赖任何组件库的前提下使用 SolidJS 重写了全部页面。 20 | 21 | 目前软件页面功能尚未达到完全体,但也只有少部分的功能需要添加,比如添加按钮实现接收完成的文件移动到任何目录中等。 22 | 23 | #### 客户端页面 24 | 25 | 在 tauri 未完成对手机操作系统的完全或大体适配前,客户端即为手机浏览器。 26 | 27 | 手机端页面也是使用 SolidJS 完成,所有组件均为本程序定制,不具有通用性,而且我的前端开发经验尚不足,**一些组件的实现可能需要改进**,欢迎有经验的前端工程师贡献代码。 28 | 29 | 当前对手机上传进行了并发控制,并发量为2,但此数字我没有经过严格测试,只是为了能够保证并发上传的前提下浏览器不会因为内存占用过高而重载页面。如果你有兴趣,可以对并发量与文件尺寸的对应关系进行改进,避免大、小文件都被限制为 2 并发。 30 | 31 | ### Rust 32 | 33 | 后端由两部分组成,一个是负责绘制窗口的 tauri,一个是负责文件接收与发送的 server。 34 | 35 | #### tauri 36 | 37 | 此部分提供前后端交互 api,前端任何想要与系统交互的代码全部通过 tauri::Command 实现。其中最主要的便是文件接收进度向前端的传输。 38 | 39 | #### server 40 | 41 | 本地服务器用来与客户端交互,本质上是一个本地文件服务器。 42 | 43 | 本软件的开发目的是实现点对点的传输,但当前的 server 尚未对此进行限制,在 server 运行后,可能会被除了目标客户端外的其他客户端影响。 44 | 45 | 前段有提到,本软件是为了点对点传输,所以广播发现等功能不在考虑之列。 46 | 47 | ## 其他 48 | 49 | 前文提到的一些可改进的地方和不考虑的功能均只是一部分,如果有其他疑问,可以通过 issue 提问。 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /docs/images/dark/1-home.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/1-home.avif -------------------------------------------------------------------------------- /docs/images/dark/10-mobile-download-list.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/10-mobile-download-list.avif -------------------------------------------------------------------------------- /docs/images/dark/2-receive-qrcode.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/2-receive-qrcode.avif -------------------------------------------------------------------------------- /docs/images/dark/3-mobile-send-index.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/3-mobile-send-index.avif -------------------------------------------------------------------------------- /docs/images/dark/4-pc-receive-empty.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/4-pc-receive-empty.avif -------------------------------------------------------------------------------- /docs/images/dark/5-mobile-uploading.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/5-mobile-uploading.avif -------------------------------------------------------------------------------- /docs/images/dark/6-pc-receiving.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/6-pc-receiving.avif -------------------------------------------------------------------------------- /docs/images/dark/7-wait-selecting.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/7-wait-selecting.avif -------------------------------------------------------------------------------- /docs/images/dark/8-selected.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/8-selected.avif -------------------------------------------------------------------------------- /docs/images/dark/9-send-qrcode.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/dark/9-send-qrcode.avif -------------------------------------------------------------------------------- /docs/images/light/1-home.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/1-home.avif -------------------------------------------------------------------------------- /docs/images/light/2-receive-qrcode.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/2-receive-qrcode.avif -------------------------------------------------------------------------------- /docs/images/light/4-pc-receive-empty.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/4-pc-receive-empty.avif -------------------------------------------------------------------------------- /docs/images/light/5-mobile-uploading.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/5-mobile-uploading.avif -------------------------------------------------------------------------------- /docs/images/light/6-pc-receiving.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/6-pc-receiving.avif -------------------------------------------------------------------------------- /docs/images/light/7-wait-selecting.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/7-wait-selecting.avif -------------------------------------------------------------------------------- /docs/images/light/8-selected.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/8-selected.avif -------------------------------------------------------------------------------- /docs/images/light/9-send-qrcode.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/9-send-qrcode.avif -------------------------------------------------------------------------------- /docs/images/light/mobile-download-list.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/mobile-download-list.avif -------------------------------------------------------------------------------- /docs/images/light/mobile-send-index.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/docs/images/light/mobile-send-index.avif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 小路速传 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluxy", 3 | "private": true, 4 | "version": "0.1.17", 5 | "type": "module", 6 | "scripts": { 7 | "install:all": "bun i && cd static && bun i", 8 | "dev:vite": "vite", 9 | "build:vite": "tsc && vite build && cd static && tsc && vite build", 10 | "build:static": "cd static && tsc && vite build", 11 | "build": "tauri build", 12 | "tauri": "tauri", 13 | "start:dev": "cross-env RUST_BACKTRACE=1 tauri dev", 14 | "trace": "cross-env RUST_BACKTRACE=1 tauri dev", 15 | "preview": "bun build:static && cross-env RUST_BACKTRACE=1 tauri dev --release", 16 | "dev": "bun start:dev", 17 | "update:all": "bun update && cd static && bun update && cd ../src-tauri && cargo update" 18 | }, 19 | "dependencies": { 20 | "@tauri-apps/api": "^1.6.0", 21 | "alley-components": "^0.3.11", 22 | "solid-icons": "^1.1.0", 23 | "solid-js": "^1.9.3" 24 | }, 25 | "devDependencies": { 26 | "@tauri-apps/cli": "^1.6.3", 27 | "@types/node": "^22.10.2", 28 | "cross-env": "^7.0.3", 29 | "sass": "^1.83.0", 30 | "typescript": "^5.7.2", 31 | "vite": "^5.4.11", 32 | "vite-plugin-solid": "^2.11.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/public/icon.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/public/logo.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fluxy" 3 | version = "0.0.0" 4 | description = "局域网文件传输工具" 5 | authors = ["thep0y"] 6 | license = "AGPL-3.0" 7 | repository = "https://github.com/alley-rs/alley-transfer" 8 | edition = "2021" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "1", features = [] } 14 | 15 | [dependencies] 16 | tauri = { version = "1", features = [ 17 | "dialog-message", 18 | "dialog-confirm", 19 | "clipboard-write-text", 20 | "updater", 21 | "dialog-open", 22 | "shell-open", 23 | ] } 24 | serde = { version = "1", features = ["derive"] } 25 | serde_json = "1" 26 | dirs = "5" 27 | lazy_static = "1" 28 | salvo = { version = "0", features = ["serve-static"] } 29 | tokio = { version = "1", features = ["macros"] } 30 | tokio-util = "0" 31 | local-ip-address = "0" 32 | qrcode-generator = "5" 33 | bytes = '1' 34 | futures = "0" 35 | time = { version = "0", features = ['macros'] } 36 | thiserror = "2" 37 | tracing-subscriber = { version = "0", features = [ 38 | 'time', 39 | 'env-filter', 40 | 'json', 41 | ] } 42 | tracing = { version = "0", features = ["log", "release_max_level_info"] } 43 | tracing-appender = '0' 44 | rust-embed = "8" 45 | sys-locale = "0" 46 | 47 | 48 | [features] 49 | # this feature is used for production builds or when `devPath` points to the filesystem 50 | # DO NOT REMOVE!! 51 | custom-protocol = ["tauri/custom-protocol"] 52 | 53 | [profile.release] 54 | panic = "abort" # Strip expensive panic clean-up logic 55 | codegen-units = 1 # Compile crates one after another so the compiler can optimize better 56 | lto = true # Enables link to optimizations 57 | opt-level = "s" # Optimize for binary size 58 | strip = true # Remove debug symbols 59 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/linux/alley.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=Office;Network;Utility 3 | Comment=局域网内终端间文件互传 4 | Exec={{exec}} 5 | Icon={{icon}} 6 | Name=小路速传 7 | Terminal=false 8 | Type=Application -------------------------------------------------------------------------------- /src-tauri/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTimeError; 2 | 3 | use qrcode_generator::QRCodeError; 4 | use serde::{Serialize, Serializer}; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | pub(crate) enum FluxyError { 8 | // #[error(transparent)] 9 | // SetLogger(#[from] SetLoggerError), 10 | #[error(transparent)] 11 | SystemTime(#[from] SystemTimeError), 12 | #[error(transparent)] 13 | Tauro(#[from] tauri::Error), 14 | #[error(transparent)] 15 | QRCode(#[from] QRCodeError), 16 | #[error(transparent)] 17 | Io(#[from] std::io::Error), 18 | #[cfg(target_os = "linux")] 19 | #[error(transparent)] 20 | EnvVar(#[from] std::env::VarError), 21 | } 22 | 23 | impl Serialize for FluxyError { 24 | fn serialize(&self, serializer: S) -> std::result::Result 25 | where 26 | S: Serializer, 27 | { 28 | serializer.serialize_str(self.to_string().as_ref()) 29 | } 30 | } 31 | 32 | pub(crate) type FluxyResult = Result; 33 | -------------------------------------------------------------------------------- /src-tauri/src/i18n/en_us.rs: -------------------------------------------------------------------------------- 1 | use super::Translations; 2 | 3 | pub(super) const EN_US: &Translations = &Translations { 4 | window_title: "Fluxy", 5 | dark_mode_tooltip: "Switch to Light Mode", 6 | light_mode_tooltip: "Switch to Dark Mode", 7 | about_button_tooltip: "About and Help", 8 | about_dialog_github_tooltip: "Visit Official Website", 9 | about_dialog_feedback_tooltip: "Provide Feedback or Get Help", 10 | home_label_text: "Select Transfer Method", 11 | home_button_text: "Back to Home", 12 | home_receive_button_text: "Receive", 13 | home_send_button_text: "Send", 14 | qrcode_page_title: "Scan to Connect", 15 | qrcode_page_url_label: "Or visit in another computer's browser", 16 | qrcode_page_url_tooltip: "Copy Link to Clipboard", 17 | qrcode_page_url_copied_message: "Link Copied", 18 | qrcode_page_toast_message: "Please scan this QR code with your phone", 19 | ok_button_text: "Confirm", 20 | clear_button_text: "Clear File List", 21 | send_page_title: "Send Files", 22 | send_page_empty_drop_description: "Drag files here", 23 | send_page_drop_description: "You can continue dragging more files", 24 | list_item_file_size_label: "Size", 25 | list_item_file_type_label: "Type", 26 | send_page_list_item_tooltip: "Click to Preview File", 27 | receive_page_empty_description: "Please upload files from your phone or another computer", 28 | receive_page_list_item_tooltip: "Click to Open File with Default Program", 29 | receive_page_dropdown_open_button_label: "Open", 30 | receive_page_dropdown_pick_button_label: "Change", 31 | receive_page_directory_path_label: "Save Directory", 32 | receive_page_directory_path_tooltip: "Click to Open Directory in File Explorer", 33 | }; 34 | -------------------------------------------------------------------------------- /src-tauri/src/i18n/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::LazyLock}; 2 | 3 | use serde::Serialize; 4 | 5 | mod en_us; 6 | mod zh_cn; 7 | 8 | pub static LOCALES: LazyLock> = LazyLock::new(|| { 9 | [(&Locale::ZhCN, zh_cn::ZH_CN), (&Locale::EnUS, en_us::EN_US)] 10 | .iter() 11 | .copied() 12 | .collect() 13 | }); 14 | 15 | #[derive(PartialEq, Eq, Hash)] 16 | pub enum Locale { 17 | ZhCN, 18 | EnUS, 19 | } 20 | 21 | impl From for Locale { 22 | #[cfg(not(target_os = "macos"))] 23 | fn from(value: String) -> Self { 24 | match value.as_ref() { 25 | "zh-CN" => Self::ZhCN, 26 | _ => Self::EnUS, 27 | } 28 | } 29 | 30 | #[cfg(target_os = "macos")] 31 | fn from(value: String) -> Self { 32 | match value.as_ref() { 33 | "zh-Hans-CN" => Self::ZhCN, 34 | _ => Self::EnUS, 35 | } 36 | } 37 | } 38 | 39 | #[derive(PartialEq, Eq, Hash, Serialize)] 40 | pub struct Translations { 41 | pub window_title: &'static str, 42 | pub dark_mode_tooltip: &'static str, 43 | pub light_mode_tooltip: &'static str, 44 | pub about_button_tooltip: &'static str, 45 | pub about_dialog_github_tooltip: &'static str, 46 | pub about_dialog_feedback_tooltip: &'static str, 47 | pub home_label_text: &'static str, 48 | pub home_button_text: &'static str, 49 | pub home_send_button_text: &'static str, 50 | pub home_receive_button_text: &'static str, 51 | pub qrcode_page_title: &'static str, 52 | pub qrcode_page_url_label: &'static str, 53 | pub qrcode_page_url_tooltip: &'static str, 54 | pub qrcode_page_url_copied_message: &'static str, 55 | pub qrcode_page_toast_message: &'static str, 56 | pub ok_button_text: &'static str, 57 | pub clear_button_text: &'static str, 58 | pub send_page_title: &'static str, 59 | pub send_page_empty_drop_description: &'static str, 60 | pub send_page_drop_description: &'static str, 61 | pub list_item_file_size_label: &'static str, 62 | pub list_item_file_type_label: &'static str, 63 | pub send_page_list_item_tooltip: &'static str, 64 | pub receive_page_empty_description: &'static str, 65 | pub receive_page_list_item_tooltip: &'static str, 66 | pub receive_page_dropdown_open_button_label: &'static str, 67 | pub receive_page_dropdown_pick_button_label: &'static str, 68 | pub receive_page_directory_path_label: &'static str, 69 | pub receive_page_directory_path_tooltip: &'static str, 70 | } 71 | -------------------------------------------------------------------------------- /src-tauri/src/i18n/zh_cn.rs: -------------------------------------------------------------------------------- 1 | use super::Translations; 2 | 3 | pub(super) const ZH_CN: &Translations = &Translations { 4 | window_title: "小路速传", 5 | dark_mode_tooltip: "切换为亮色", 6 | light_mode_tooltip: "切换为暗色", 7 | about_button_tooltip: "关于和帮助", 8 | about_dialog_github_tooltip: "访问官网", 9 | about_dialog_feedback_tooltip: "反馈问题或寻求帮助", 10 | home_label_text: "选择传输方式", 11 | home_button_text: "回到主页", 12 | home_receive_button_text: "接收", 13 | home_send_button_text: "发送", 14 | qrcode_page_title: "扫码连接", 15 | qrcode_page_url_label: "或在另一台电脑中通过浏览器中访问", 16 | qrcode_page_url_tooltip: "复制链接到剪贴板", 17 | qrcode_page_url_copied_message: "已复制链接", 18 | qrcode_page_toast_message: "请使用手机扫描此二维码", 19 | ok_button_text: "确认", 20 | clear_button_text: "清空文件列表", 21 | send_page_title: "发送文件", 22 | send_page_empty_drop_description: "将文件拖到此处", 23 | send_page_drop_description: "可继续拖入文件", 24 | list_item_file_size_label: "大小", 25 | list_item_file_type_label: "类型", 26 | send_page_list_item_tooltip: "单击预览文件", 27 | receive_page_empty_description: "请在手机端或者另一台电脑中上传文件", 28 | receive_page_list_item_tooltip: "单击使用默认程序打开此文件", 29 | receive_page_dropdown_open_button_label: "打开", 30 | receive_page_dropdown_pick_button_label: "修改", 31 | receive_page_directory_path_label: "保存目录", 32 | receive_page_directory_path_tooltip: "单击在文件管理器中打开此目录", 33 | }; 34 | -------------------------------------------------------------------------------- /src-tauri/src/lazy.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use local_ip_address::local_ip; 4 | 5 | lazy_static! { 6 | pub(super) static ref LOCAL_IP: String = local_ip().expect("获取本地ip失败").to_string(); 7 | pub(super) static ref APP_CONFIG_DIR: PathBuf = { 8 | let config_dir = dirs::config_dir().unwrap(); 9 | 10 | let app_config_dir = config_dir.join("fluxy"); 11 | 12 | if !app_config_dir.exists() { 13 | fs::create_dir(&app_config_dir).unwrap(); 14 | } 15 | 16 | app_config_dir 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src-tauri/src/linux.rs: -------------------------------------------------------------------------------- 1 | use std::{env, process::Command}; 2 | 3 | use crate::error::FluxyResult; 4 | 5 | pub(super) fn get_scale_factor() -> FluxyResult { 6 | let desktop_session = env::var("DESKTOP_SESSION").map_err(|e| { 7 | error!(message = "从环境变量中获取桌面会话失败", error = ?e); 8 | e 9 | })?; 10 | 11 | info!(message = "已获取当前桌面会话", desktop = desktop_session); 12 | 13 | match desktop_session.as_str() { 14 | "deepin" => { 15 | let output = Command::new("gsettings") 16 | .args(["get", "com.deepin.xsettings", "scale-factor"]) 17 | .output() 18 | .map_err(|e| { 19 | error!(message = "获取桌面缩放比例失败", error = ?e); 20 | e 21 | })?; 22 | 23 | let s = String::from_utf8(output.stdout).unwrap(); 24 | 25 | Ok(s.trim().parse().unwrap()) 26 | } 27 | _ => Ok(1.0), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | mod error; 5 | mod i18n; 6 | mod lazy; 7 | #[cfg(target_os = "linux")] 8 | mod linux; 9 | #[cfg(target_os = "macos")] 10 | mod menu; 11 | mod server; 12 | mod stream; 13 | 14 | #[macro_use] 15 | extern crate lazy_static; 16 | #[macro_use] 17 | extern crate tracing; 18 | 19 | use std::{ 20 | path::PathBuf, 21 | time::{Duration, SystemTime, UNIX_EPOCH}, 22 | }; 23 | 24 | use qrcode_generator::QrCodeEcc; 25 | use serde::Serialize; 26 | use sys_locale::get_locale; 27 | use tauri::{AppHandle, Manager, UpdaterEvent}; 28 | use time::macros::{format_description, offset}; 29 | use tokio::fs::File; 30 | use tracing::Level; 31 | use tracing_subscriber::fmt::time::OffsetTime; 32 | 33 | use crate::i18n::{Locale, Translations, LOCALES}; 34 | use crate::lazy::LOCAL_IP; 35 | #[cfg(target_os = "macos")] 36 | use crate::menu::{handle_menu_event, new_menu}; 37 | use crate::server::{SendFile, DOWNLOADS_DIR, MAIN_WINDOW, QR_CODE_MAP, SEND_FILES}; 38 | use crate::{error::FluxyResult, lazy::APP_CONFIG_DIR}; 39 | 40 | fn now() -> FluxyResult { 41 | SystemTime::now().duration_since(UNIX_EPOCH).map_err(|e| { 42 | error!(message = "获取时间出错", error = ?e); 43 | e.into() 44 | }) 45 | } 46 | 47 | enum Mode { 48 | Send, 49 | Receive, 50 | } 51 | 52 | impl Mode { 53 | fn to_str(&self) -> &'static str { 54 | match self { 55 | Mode::Send => "send", 56 | Mode::Receive => "receive", 57 | } 58 | } 59 | } 60 | 61 | #[derive(Debug, Serialize)] 62 | struct QrCode { 63 | svg: String, 64 | url: String, 65 | id: u64, 66 | } 67 | 68 | impl QrCode { 69 | fn new(mode: Mode) -> FluxyResult { 70 | let ts = now()?.as_secs(); 71 | debug!(message = "获取到时间戳", ts = ts); 72 | 73 | let url = format!( 74 | "http://{}:{}/connect?mode={}&ts={}", 75 | *LOCAL_IP, 76 | 5800, 77 | mode.to_str(), 78 | ts 79 | ); 80 | debug!(message = "二维码信息", url = url); 81 | 82 | let code = qrcode_generator::to_svg_to_string(&url, QrCodeEcc::Low, 256, None::<&str>) 83 | .map_err(|e| { 84 | error!(message = "创建二维码失败", error = ?e); 85 | e 86 | })?; 87 | 88 | info!("已创建二维码"); 89 | 90 | Ok(Self { 91 | svg: code, 92 | url, 93 | id: ts, 94 | }) 95 | } 96 | } 97 | 98 | #[tauri::command] 99 | async fn get_qr_code_state(id: u64) -> bool { 100 | trace!("获取 server 地址二维码状态"); 101 | 102 | let map = QR_CODE_MAP.read().await; 103 | let state = map.contains_key(&id); 104 | 105 | info!(message = "server 地址二维码可用状态", state = !state); 106 | 107 | state 108 | } 109 | 110 | #[tauri::command] 111 | async fn upload_qr_code() -> FluxyResult { 112 | trace!("获取上传地址二维码"); 113 | 114 | let code = QrCode::new(Mode::Send)?; 115 | 116 | info!( 117 | message = "上传地址二维码已创建", 118 | url = code.url, 119 | svg = code.svg 120 | ); 121 | 122 | Ok(code) 123 | } 124 | 125 | #[tauri::command] 126 | async fn get_send_files_url_qr_code(files: Vec) -> FluxyResult { 127 | trace!("获取发送址二维码"); 128 | let mut send_files = SEND_FILES.write().await; 129 | *send_files = Some(files); 130 | 131 | let code = QrCode::new(Mode::Receive)?; 132 | 133 | info!( 134 | message = "发送地址二维码已创建", 135 | url = code.url, 136 | svg = code.svg 137 | ); 138 | 139 | Ok(code) 140 | } 141 | 142 | #[tauri::command] 143 | async fn downloads_dir() -> PathBuf { 144 | trace!("获取下载目录"); 145 | 146 | let path = DOWNLOADS_DIR.read().await.to_path_buf(); 147 | 148 | info!(message = "当前下载目录为", dir = ?path); 149 | 150 | path 151 | } 152 | 153 | #[tauri::command] 154 | async fn change_downloads_dir(path: PathBuf) { 155 | trace!("修改下载目录"); 156 | 157 | let mut downloads_dir = DOWNLOADS_DIR.write().await; 158 | downloads_dir.clone_from(&path); 159 | 160 | info!(message = "下载目录已修改", dir = ?path); 161 | } 162 | 163 | #[tauri::command] 164 | async fn get_files_metadata(paths: Vec) -> FluxyResult> { 165 | trace!("获取待发送文件信息"); 166 | let mut files = Vec::with_capacity(paths.len()); 167 | 168 | for path in paths.iter() { 169 | if path.is_dir() { 170 | continue; 171 | } 172 | 173 | let filename = path.file_name().unwrap().to_str().unwrap(); 174 | let extension = path.extension().unwrap().to_str().unwrap(); 175 | let file = File::open(path).await.map_err(|e| { 176 | error!(message = "打开文件失败", path = ?path, error = ?e); 177 | e 178 | })?; 179 | let size = file 180 | .metadata() 181 | .await 182 | .map_err(|e| { 183 | error!(message = "获取文件元信息失败", path = ?path, error = ?e); 184 | e 185 | })? 186 | .len(); 187 | 188 | files.push(SendFile::new(filename, path, extension, size)) 189 | } 190 | 191 | info!("所有待发送文件信息: {:?}", files); 192 | 193 | Ok(files) 194 | } 195 | 196 | #[tauri::command] 197 | fn get_locale_translations() -> &'static Translations { 198 | let locale: Locale = get_locale().unwrap_or_else(|| String::from("en-US")).into(); 199 | LOCALES[&locale] 200 | } 201 | 202 | #[tauri::command] 203 | fn is_linux() -> bool { 204 | cfg!(target_os = "linux") 205 | } 206 | 207 | /// 防止启动时闪白屏 208 | #[tauri::command] 209 | async fn show_main_window(app: AppHandle) { 210 | debug!("Showing and focusing main window"); 211 | 212 | let main_window = app.get_window("main").unwrap(); 213 | 214 | main_window.show().unwrap(); 215 | main_window.set_focus().unwrap(); 216 | } 217 | 218 | #[tokio::main] 219 | async fn main() -> FluxyResult<()> { 220 | #[cfg(debug_assertions)] 221 | let timer = OffsetTime::new( 222 | offset!(+8), 223 | format_description!("[hour]:[minute]:[second].[subsecond digits:3]"), 224 | ); 225 | #[cfg(not(debug_assertions))] 226 | let timer = OffsetTime::new( 227 | offset!(+8), 228 | format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:3]"), 229 | ); 230 | 231 | // NOTE: _guard must be a top-level variable 232 | let (writer, _guard) = { 233 | let file_appender = tracing_appender::rolling::never(&*APP_CONFIG_DIR, "fluxy.log"); 234 | tracing_appender::non_blocking(file_appender) 235 | }; 236 | 237 | #[cfg(debug_assertions)] 238 | let writer = { 239 | use tracing_subscriber::fmt::writer::MakeWriterExt; 240 | std::io::stderr.and(writer) 241 | }; 242 | 243 | let builder = tracing_subscriber::fmt() 244 | .with_max_level(Level::TRACE) 245 | .with_file(true) 246 | .with_line_number(true) 247 | .with_env_filter("fluxy") 248 | .with_timer(timer) 249 | .with_writer(writer); 250 | 251 | #[cfg(debug_assertions)] 252 | builder.init(); 253 | 254 | #[cfg(not(debug_assertions))] 255 | builder.json().init(); 256 | 257 | let locale = get_locale().unwrap_or_else(|| String::from("en-US")); 258 | debug!("current locale: {}", locale); 259 | let locale: Locale = locale.into(); 260 | let translations = LOCALES[&locale]; 261 | 262 | #[cfg(target_os = "linux")] 263 | { 264 | let scale_factor = crate::linux::get_scale_factor()?; 265 | if scale_factor.fract() != 0.0 { 266 | info!(message = "当前显示器非整数倍缩放", factor = scale_factor); 267 | std::env::set_var("GDK_SCALE", "2"); 268 | std::env::set_var("GDK_DPI_SCALE", "0.5"); 269 | info!("已为当前程序设置 GDK 缩放比例"); 270 | } 271 | } 272 | 273 | tokio::spawn(server::serve()); 274 | info!("已创建 serve 线程"); 275 | 276 | #[allow(unused_mut)] 277 | let mut builder = tauri::Builder::default() 278 | .setup(|app| { 279 | if let Some(w) = app.get_window("main") { 280 | w.set_title(translations.window_title).unwrap(); 281 | if MAIN_WINDOW.set(w).is_err() { 282 | error!(message = "设置主窗口失败"); 283 | app.handle().exit(1); 284 | } 285 | } 286 | Ok(()) 287 | }) 288 | .invoke_handler(tauri::generate_handler![ 289 | upload_qr_code, 290 | get_qr_code_state, 291 | downloads_dir, 292 | change_downloads_dir, 293 | get_files_metadata, 294 | get_send_files_url_qr_code, 295 | is_linux, 296 | show_main_window, 297 | get_locale_translations 298 | ]); 299 | 300 | // windows 和 linux 的菜单在窗口内, 无法自动切换暗色, 所以不使用菜单 301 | #[cfg(target_os = "macos")] 302 | { 303 | builder = builder 304 | .menu(new_menu()) 305 | .on_menu_event(|event| handle_menu_event(event.window(), event.menu_item_id())); 306 | } 307 | 308 | let app = builder.build(tauri::generate_context!()).map_err(|e| { 309 | error!(message = "创建 app 失败", error = ?e); 310 | e 311 | })?; 312 | 313 | app.run(|_app_handle, event| { 314 | if let tauri::RunEvent::Updater(e) = event { 315 | match e { 316 | UpdaterEvent::UpdateAvailable { 317 | body, 318 | date, 319 | version, 320 | } => { 321 | info!(message = "版本有更新", body = body, date = ?date, version = version); 322 | } 323 | UpdaterEvent::Pending => { 324 | info!("准备下载新版本"); 325 | } 326 | UpdaterEvent::DownloadProgress { 327 | chunk_length, 328 | content_length, 329 | } => { 330 | trace!("正在下载: {}/{:?}", chunk_length, content_length); 331 | } 332 | UpdaterEvent::Downloaded => { 333 | info!("新版本已下载"); 334 | } 335 | UpdaterEvent::Updated => { 336 | info!("更新完成"); 337 | } 338 | UpdaterEvent::AlreadyUpToDate => { 339 | info!("当前已是最新版本"); 340 | } 341 | UpdaterEvent::Error(e) => { 342 | error!(message = "更新失败", error = e); 343 | } 344 | } 345 | } 346 | }); 347 | 348 | Ok(()) 349 | } 350 | -------------------------------------------------------------------------------- /src-tauri/src/menu.rs: -------------------------------------------------------------------------------- 1 | use tauri::{ 2 | api::shell::open, AboutMetadata, CustomMenuItem, Manager, Menu, MenuItem, Submenu, Window, 3 | }; 4 | 5 | pub(super) fn new_menu() -> Menu { 6 | let issue = CustomMenuItem::new("issue".to_string(), "反馈"); 7 | let website = CustomMenuItem::new("website".to_string(), "官网"); 8 | 9 | let menu = Menu::new() 10 | .add_item(website) 11 | .add_item(issue) 12 | .add_native_item(MenuItem::Separator) 13 | .add_native_item(MenuItem::About("".to_owned(), AboutMetadata::new())); 14 | 15 | let submenu = Submenu::new("fluxy", menu); 16 | 17 | Menu::new().add_submenu(submenu) 18 | } 19 | 20 | pub(super) fn handle_menu_event(window: &Window, id: &str) { 21 | match id { 22 | "website" => open( 23 | &window.shell_scope(), 24 | "https://github.com/alley-rs/fluxy", 25 | None, 26 | ) 27 | .unwrap(), 28 | "issue" => open( 29 | &window.shell_scope(), 30 | "https://github.com/alley-rs/fluxy/issues", 31 | None, 32 | ) 33 | .unwrap(), 34 | _ => unreachable!(), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src-tauri/src/server/error.rs: -------------------------------------------------------------------------------- 1 | use salvo::{async_trait, http::StatusCode, writing::Json, Depot, Request, Response, Writer}; 2 | use serde::Serialize; 3 | 4 | pub(super) type ServerResult = std::result::Result; 5 | 6 | #[derive(Serialize)] 7 | #[serde(untagged)] 8 | pub(super) enum ServerError { 9 | Bad { 10 | error: String, 11 | advice: Option, 12 | }, 13 | Internal, 14 | } 15 | 16 | impl ServerError { 17 | pub(super) fn new<'a, O1: Into>, O2: Into>>( 18 | error: O1, 19 | advice: O2, 20 | ) -> Self { 21 | let msg: Option<&str> = error.into(); 22 | let advice: Option<&str> = advice.into(); 23 | let advice = advice.map(|s| s.to_owned()); 24 | 25 | match msg { 26 | None => Self::Internal, 27 | Some(s) => Self::Bad { 28 | error: s.into(), 29 | advice, 30 | }, 31 | } 32 | } 33 | } 34 | 35 | #[async_trait] 36 | impl Writer for ServerError { 37 | async fn write(self, _req: &mut Request, _depot: &mut Depot, res: &mut Response) { 38 | match &self { 39 | ServerError::Bad { .. } => { 40 | res.status_code(StatusCode::BAD_REQUEST); 41 | res.render(Json(&self)); 42 | } 43 | ServerError::Internal => { 44 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src-tauri/src/server/logger.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use salvo::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; 4 | use salvo::http::{Request, Response, StatusCode}; 5 | use salvo::{async_trait, Depot, FlowCtrl, Handler}; 6 | 7 | pub(super) struct Logger; 8 | 9 | impl Logger { 10 | #[inline] 11 | pub fn new() -> Self { 12 | Logger {} 13 | } 14 | } 15 | 16 | #[async_trait] 17 | impl Handler for Logger { 18 | async fn handle( 19 | &self, 20 | req: &mut Request, 21 | depot: &mut Depot, 22 | res: &mut Response, 23 | ctrl: &mut FlowCtrl, 24 | ) { 25 | async move { 26 | let now = Instant::now(); 27 | ctrl.call_next(req, depot, res).await; 28 | let duration = now.elapsed(); 29 | 30 | let status = res.status_code.unwrap_or(StatusCode::OK); 31 | 32 | let headers = req.headers(); 33 | 34 | info!( 35 | method = ?req.method(), 36 | status = status.as_u16(), 37 | duration = ?duration, 38 | path = ?req.uri().path(), 39 | client_ip = ?req.remote_addr(), 40 | content_type = ?headers.get(CONTENT_TYPE), 41 | content_length = ?headers.get(CONTENT_LENGTH), 42 | queries = ?req.queries(), 43 | ); 44 | } 45 | .await 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/src/stream.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use futures::stream::Stream; 3 | use futures::task::{Context, Poll}; 4 | use salvo::http::{Body, ReqBody}; 5 | use std::cell::OnceCell; 6 | use std::io::Result; 7 | use std::pin::Pin; 8 | use std::time::{Duration, Instant}; 9 | 10 | pub(super) type ProgressHandler = Box; 11 | 12 | pub(super) struct ReadProgressStream { 13 | inner: ReqBody, 14 | bytes_read: u64, 15 | progress: ProgressHandler, 16 | start: OnceCell, 17 | } 18 | 19 | impl ReadProgressStream { 20 | pub(super) fn new(inner: ReqBody, progress: ProgressHandler) -> Self { 21 | ReadProgressStream { 22 | inner, 23 | progress, 24 | bytes_read: 0, 25 | start: OnceCell::new(), 26 | } 27 | } 28 | } 29 | 30 | impl Stream for ReadProgressStream { 31 | type Item = Result; 32 | 33 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 34 | let start = self.start.get_or_init(|| Instant::now()).clone(); 35 | 36 | let body = &mut self.inner; 37 | 38 | match Body::poll_frame(Pin::new(body), cx) { 39 | Poll::Ready(Some(Ok(frame))) => { 40 | let bytes: Bytes = frame.into_data().unwrap(); 41 | 42 | let current_time = Instant::now(); 43 | let cost = current_time.duration_since(start); 44 | 45 | let mut bytes_read = self.bytes_read; 46 | 47 | bytes_read += bytes.len() as u64; 48 | 49 | (self.progress)(cost, bytes_read); 50 | 51 | self.bytes_read = bytes_read; 52 | 53 | Poll::Ready(Some(Ok(bytes))) 54 | } 55 | Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), 56 | Poll::Ready(None) => Poll::Ready(None), 57 | Poll::Pending => Poll::Pending, 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "bun run dev:vite", 4 | "beforeBuildCommand": "bun run build:vite", 5 | "devPath": "http://localhost:1420/", 6 | "withGlobalTauri": false 7 | }, 8 | "package": { 9 | "productName": "fluxy", 10 | "version": "../package.json" 11 | }, 12 | "tauri": { 13 | "allowlist": { 14 | "all": false, 15 | "shell": { 16 | "all": false, 17 | "open": "^((/Users/.+)|(C:\\\\Users\\\\.+)|(/home/.+)|(http(s)?://.+)).+" 18 | }, 19 | "dialog": { 20 | "all": false, 21 | "open": true, 22 | "message": true, 23 | "confirm": true 24 | }, 25 | "clipboard": { 26 | "all": false, 27 | "writeText": true 28 | } 29 | }, 30 | "bundle": { 31 | "active": true, 32 | "targets": "all", 33 | "deb": { 34 | "desktopTemplate": "./linux/alley.desktop" 35 | }, 36 | "windows": { 37 | "wix": { "language": ["zh-CN", "en-US"] }, 38 | "nsis": { 39 | "languages": ["SimpChinese", "English"], 40 | "displayLanguageSelector": true 41 | } 42 | }, 43 | "identifier": "com.thepoy.alley", 44 | "icon": [ 45 | "icons/32x32.png", 46 | "icons/128x128.png", 47 | "icons/128x128@2x.png", 48 | "icons/icon.icns", 49 | "icons/icon.ico" 50 | ] 51 | }, 52 | "updater": { 53 | "active": true, 54 | "endpoints": [ 55 | "https://app.thepoy.cc/alley-transfer/latest-mirror-1.json", 56 | "https://app.thepoy.cc/alley-transfer/latest-mirror-2.json", 57 | "https://github.com/alley-rs/fluxy/releases/latest/download/latest.json" 58 | ], 59 | "dialog": true, 60 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDcxQTQ5NDZBNUIyMEVDRTUKUldUbDdDQmJhcFNrY2RYSkpGNUt0U3cvdEozMXJoN2pXeEFBcUQ4YmZMTi9MS2E2YjNQT1pSbTgK" 61 | }, 62 | "security": { 63 | "csp": null 64 | }, 65 | "windows": [ 66 | { 67 | "title": "小路速传", 68 | "width": 400, 69 | "height": 600, 70 | "resizable": false, 71 | "visible": false, 72 | "fileDropEnabled": true 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 100vh; 3 | width: 100vw; 4 | text-align: center; 5 | display: flex; 6 | overflow: hidden; 7 | 8 | .dark-switch { 9 | position: fixed; 10 | right: 20px; 11 | top: 10px; 12 | } 13 | } 14 | 15 | #index { 16 | flex: 1; 17 | height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: center; 21 | align-items: center; 22 | gap: 20px; 23 | padding: 0 3rem; 24 | 25 | .alley-button.fill { 26 | width: 100%; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch, createResource, createSignal, onMount } from "solid-js"; 2 | import { TbArrowsTransferUp, TbArrowsTransferDown } from "solid-icons/tb"; 3 | import { BiRegularSun, BiSolidMoon } from "solid-icons/bi"; 4 | import { 5 | LazyAboutButton, 6 | LazyButton, 7 | LazyDialog, 8 | LazyReceive, 9 | LazySend, 10 | LazySwitch, 11 | LazyTooltip, 12 | } from "./lazy"; 13 | import { suspense } from "./advance"; 14 | import "~/App.scss"; 15 | import useDark from "alley-components/lib/hooks/useDark"; 16 | import About from "./about"; 17 | import { AppContext } from "./context"; 18 | import { getLocaleTranslations, showMainWindow } from "./api"; 19 | 20 | enum Mode { 21 | Send = 1, 22 | Receive = 2, 23 | } 24 | 25 | const App = () => { 26 | const [isDark, setIsDark] = useDark(); 27 | 28 | const [mode, setMode] = createSignal(null); 29 | const [showAbout, setShowAbout] = createSignal(false); 30 | const [translations] = createResource(getLocaleTranslations); 31 | 32 | const goHome = () => setMode(null); 33 | 34 | onMount(() => showMainWindow()); 35 | 36 | return ( 37 | setShowAbout(true) }, 42 | }} 43 | > 44 | 52 | { 56 | setIsDark((pre) => { 57 | return !pre; 58 | }); 59 | }} 60 | uncheckedChild={} 61 | checkedChild={} 62 | /> 63 | 64 | 65 | 68 |
{translations()?.home_label_text}
69 | 70 | {suspense( 71 | } 74 | onClick={() => setMode(Mode.Receive)} 75 | > 76 | {translations()?.home_receive_button_text} 77 | , 78 | )} 79 | 80 | {suspense( 81 | } 84 | onClick={() => setMode(Mode.Send)} 85 | > 86 | {translations()?.home_send_button_text} 87 | , 88 | )} 89 | 90 | 91 | 92 | } 93 | > 94 | 95 | {suspense()} 96 | 97 | {suspense()} 98 |
99 | 100 | setShowAbout(false)} 103 | showCloseIcon 104 | showMask 105 | > 106 | 107 | 108 |
109 | ); 110 | }; 111 | 112 | export default App; 113 | -------------------------------------------------------------------------------- /src/about.scss: -------------------------------------------------------------------------------- 1 | #about { 2 | height: 100vh; 3 | 4 | img { 5 | user-select: none; 6 | -webkit-user-drag: none; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/about.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LazyButton, 3 | LazyFlex, 4 | LazyLabel, 5 | LazySpace, 6 | LazyTooltip, 7 | LazyTypographyText, 8 | LazyTypographyTitle, 9 | } from "./lazy"; 10 | import useDark from "alley-components/lib/hooks/useDark"; 11 | import { createSignal, onMount, useContext } from "solid-js"; 12 | import { app } from "@tauri-apps/api"; 13 | import { AiFillGithub } from "solid-icons/ai"; 14 | import { RiCommunicationFeedbackLine } from "solid-icons/ri"; 15 | import { open } from "@tauri-apps/api/shell"; 16 | import "./about.scss"; 17 | import { AppContext } from "./context"; 18 | 19 | const About = () => { 20 | const { translations } = useContext(AppContext)!; 21 | const [name, setName] = createSignal(""); 22 | const [version, setVersion] = createSignal(""); 23 | 24 | useDark(); 25 | onMount(() => { 26 | app.getName().then((n) => setName(n)); 27 | app.getVersion().then((v) => setVersion(v)); 28 | }); 29 | 30 | return ( 31 | 32 | 33 | 34 | {name()} 35 | 36 | 37 | version 38 | 39 | {version()} 40 | 41 | 42 | 43 | 44 | 48 | } 50 | type="plain" 51 | shape="circle" 52 | onClick={() => open("https://github.com/alley-rs/fluxy")} 53 | /> 54 | 55 | 56 | 60 | } 62 | type="plain" 63 | shape="circle" 64 | onClick={() => open("https://github.com/alley-rs/fluxy/issues/new")} 65 | /> 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default About; 73 | -------------------------------------------------------------------------------- /src/advance/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import { Suspense } from "solid-js"; 3 | import Loading from "alley-components/lib/components/spinner"; 4 | 5 | export const suspense = (component: JSXElement) => ( 6 | }>{component} 7 | ); 8 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | 3 | export const getDownloadsDir = async () => 4 | await invoke("downloads_dir"); 5 | 6 | export const changeDownloadsDir = async (path: string) => 7 | await invoke("change_downloads_dir", { path }); 8 | 9 | export const getQrCodeState = async (id: number) => 10 | await invoke("get_qr_code_state", { 11 | id, 12 | }); 13 | 14 | export const getUploadQrCode = async () => 15 | await invoke("upload_qr_code"); 16 | 17 | export const pickFileServerDirectory = async (path: string) => 18 | await invoke("pick_file_server_directory", { path }); 19 | 20 | export const getFilesMetadata = async (paths: string[]) => 21 | await invoke("get_files_metadata", { paths }); 22 | 23 | export const getSendFilesUrlQrCode = async (files: SendFile[]) => 24 | await invoke("get_send_files_url_qr_code", { files }); 25 | 26 | export const isLinux = async () => await invoke("is_linux"); 27 | 28 | export const showMainWindow = async () => invoke("show_main_window"); 29 | 30 | export const getLocaleTranslations = async () => 31 | invoke("get_locale_translations"); 32 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/aboutButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineQuestion } from "solid-icons/ai"; 2 | import { useContext } from "solid-js"; 3 | import { AppContext } from "~/context"; 4 | import { LazyFloatButton } from "~/lazy"; 5 | 6 | const AboutButton = () => { 7 | const { 8 | about: { onShow }, 9 | translations, 10 | } = useContext(AppContext)!; 11 | 12 | return ( 13 | } 15 | tooltip={translations()!.about_button_tooltip} 16 | onClick={onShow} 17 | /> 18 | ); 19 | }; 20 | 21 | export default AboutButton; 22 | -------------------------------------------------------------------------------- /src/components/file-type-icon/index.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineFileUnknown } from "solid-icons/ai"; 2 | import { 3 | BiLogosCPlusPlus, 4 | BiLogosGoLang, 5 | BiLogosJavascript, 6 | BiLogosPython, 7 | BiLogosReact, 8 | BiLogosTypescript, 9 | } from "solid-icons/bi"; 10 | import { 11 | BsAndroid2, 12 | BsFileCode, 13 | BsFileImage, 14 | BsFilePdf, 15 | BsFileZip, 16 | BsFiletypeCss, 17 | BsFiletypeDoc, 18 | BsFiletypeDocx, 19 | BsFiletypeExe, 20 | BsFiletypeGif, 21 | BsFiletypeHtml, 22 | BsFiletypeJava, 23 | BsFiletypeJpg, 24 | BsFiletypeJson, 25 | BsFiletypeMov, 26 | BsFiletypeMp3, 27 | BsFiletypeMp4, 28 | BsFiletypePhp, 29 | BsFiletypePng, 30 | BsFiletypePpt, 31 | BsFiletypePptx, 32 | BsFiletypeScss, 33 | BsFiletypeSql, 34 | BsFiletypeSvg, 35 | BsFiletypeTxt, 36 | BsFiletypeXls, 37 | BsFiletypeXlsx, 38 | BsFiletypeXml, 39 | BsMarkdown, 40 | BsWindows, 41 | BsFiletypeTtf, 42 | BsFiletypeOtf, 43 | BsFiletypeWoff, 44 | BsFiletypeCsv, 45 | BsFileMusicFill, 46 | BsBookFill, 47 | BsFiletypeWav, 48 | } from "solid-icons/bs"; 49 | import { 50 | FaBrandsAppStore, 51 | FaBrandsLinux, 52 | FaSolidFileVideo, 53 | } from "solid-icons/fa"; 54 | import { 55 | SiDebian, 56 | SiLua, 57 | SiOpenwrt, 58 | SiRust, 59 | SiToml, 60 | SiAdobephotoshop, 61 | SiYaml, 62 | SiSqlite, 63 | SiCsharp, 64 | SiGnubash, 65 | } from "solid-icons/si"; 66 | import { 67 | RiDocumentNumbersFill, 68 | RiDocumentPagesFill, 69 | RiDocumentKeynoteFill, 70 | } from "solid-icons/ri"; 71 | 72 | const FileTypeIcon = (ext: string) => { 73 | switch (ext) { 74 | /* 视频 */ 75 | case "MP4": 76 | return ; 77 | case "MOV": 78 | return ; 79 | case "WEBM": 80 | case "FLV": 81 | case "MKV": 82 | return ; 83 | 84 | /* 音频 */ 85 | case "MP3": 86 | return ; 87 | case "WAV": 88 | return ; 89 | case "FLAC": 90 | case "APE": 91 | return ; 92 | 93 | /* 图片 */ 94 | case "JPG": 95 | case "JPEG": 96 | return ; 97 | case "GIF": 98 | return ; 99 | case "PNG": 100 | case "APNG": 101 | return ; 102 | case "SVG": 103 | return ; 104 | case "PSD": 105 | return ; 106 | case "WEBP": 107 | case "AVIF": 108 | case "ICNS": 109 | return ; 110 | 111 | /* 文档 */ 112 | case "PDF": 113 | return ; 114 | case "NUMBERS": 115 | return ; 116 | case "PAGES": 117 | return ; 118 | case "KEYNOTE": 119 | return ; 120 | case "MD": 121 | return ; 122 | case "PPT": 123 | return ; 124 | case "PPTX": 125 | return ; 126 | case "XLS": 127 | return ; 128 | case "XLSX": 129 | return ; 130 | case "DOC": 131 | return ; 132 | case "DOCX": 133 | return ; 134 | case "CSV": 135 | return ; 136 | 137 | /* 电子书 */ 138 | case "EPUB": 139 | case "MOBI": 140 | return ; 141 | 142 | /* 压缩文件 */ 143 | case "ZIP": 144 | case "RAR": 145 | case "7Z": 146 | case "TAR": 147 | case "GZ": 148 | return ; 149 | 150 | /* 应用程序 */ 151 | case "DMG": 152 | case "IPA": 153 | return ; 154 | case "EXE": 155 | return ; 156 | case "MSI": 157 | return ; 158 | case "APK": 159 | return ; 160 | case "APPIMAGE": 161 | return ; 162 | case "DEB": 163 | return ; 164 | 165 | /* 路由固件 */ 166 | case "IPK": 167 | return ; 168 | 169 | /* 代码文件 */ 170 | case "SH": 171 | return ; 172 | case "PY": 173 | return ; 174 | case "JS": 175 | return ; 176 | case "JSX": 177 | return ; 178 | case "TS": 179 | return ; 180 | case "TSX": 181 | return ; 182 | case "RS": 183 | return ; 184 | case "CPP": 185 | return ; 186 | case "JAVA": 187 | case "JAR": 188 | return ; 189 | case "LUA": 190 | return ; 191 | case "CSS": 192 | return ; 193 | case "GO": 194 | return ; 195 | case "SCSS": 196 | return ; 197 | case "PHP": 198 | return ; 199 | case "SQL": 200 | return ; 201 | case "CS": 202 | return ; 203 | case "C": 204 | return ; 205 | case "JSON": 206 | return ; 207 | case "TOML": 208 | return ; 209 | case "HTML": 210 | return ; 211 | case "XML": 212 | return ; 213 | case "YML": 214 | case "YAML": 215 | return ; 216 | case "TXT": 217 | return ; 218 | 219 | /* 字体文件 */ 220 | case "TTF": 221 | return ; 222 | case "OTF": 223 | return ; 224 | case "WOFF": 225 | return ; 226 | 227 | /* 数据库 */ 228 | case "SQLITE": 229 | case "DB": 230 | return ; 231 | 232 | default: 233 | return ; 234 | } 235 | }; 236 | 237 | export default FileTypeIcon; 238 | -------------------------------------------------------------------------------- /src/components/qrcode/index.scss: -------------------------------------------------------------------------------- 1 | .qr-code { 2 | &-svg { 3 | margin: 10px 0; 4 | } 5 | 6 | &-link { 7 | margin-top: 5px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/qrcode/index.tsx: -------------------------------------------------------------------------------- 1 | import { open } from "@tauri-apps/api/shell"; 2 | import { writeText } from "@tauri-apps/api/clipboard"; 3 | import { 4 | LazyButton, 5 | LazyFlex, 6 | LazyLink, 7 | LazySpace, 8 | LazyToast, 9 | LazyTooltip, 10 | } from "~/lazy"; 11 | import "./index.scss"; 12 | import { AiFillCopy } from "solid-icons/ai"; 13 | import { createSignal, useContext } from "solid-js"; 14 | import { AppContext } from "~/context"; 15 | 16 | interface QRCodeProps { 17 | qrcode: QrCode; 18 | } 19 | 20 | const baseClassName = "qr-code"; 21 | 22 | const QRCode = ({ qrcode }: QRCodeProps) => { 23 | const { translations } = useContext(AppContext)!; 24 | const [showToast, setShowToast] = createSignal(false); 25 | 26 | return ( 27 | 33 | { }} 38 | /> 39 | 40 |

{translations()?.qrcode_page_title}

41 |
42 | 43 |
{translations()?.qrcode_page_url_label}
44 | 45 | 46 | await open(qrcode.url)} 49 | filter={false} 50 | > 51 | {qrcode.url} 52 | 53 | 54 | 55 | } 57 | shape="circle" 58 | onClick={() => { 59 | writeText(qrcode.url); 60 | setShowToast(true); 61 | }} 62 | /> 63 | 64 | 65 | 66 | setShowToast(false)} 70 | autoHideDuration={1000} 71 | alert={{ 72 | type: "success", 73 | message: translations()?.qrcode_page_url_copied_message, 74 | }} 75 | /> 76 | 77 | ); 78 | }; 79 | 80 | export default QRCode; 81 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "solid-js"; 2 | 3 | export const AppContext = createContext(); 4 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-scrollbar: rgba(0, 0, 0, 0.1); 3 | --color-scrollbar-hover: rgba(0, 0, 0, 0.4); 4 | 5 | --alley-color-mask: rgb(0 0 0 / 30%); 6 | } 7 | 8 | ::-webkit-scrollbar { 9 | width: 8px; 10 | height: 8px; 11 | } 12 | 13 | ::-webkit-scrollbar-thumb { 14 | border-radius: 10px; 15 | background: var(--color-scrollbar); 16 | } 17 | 18 | ::-webkit-scrollbar-thumb:hover { 19 | background: var(--color-scrollbar-hover); 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | background-color: #fff0; 24 | } 25 | 26 | .dark { 27 | --color-scrollbar: rgba(255, 255, 255, 0.1); 28 | --color-scrollbar-hover: rgba(255, 255, 255, 0.2); 29 | } 30 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | import "alley-components/lib/index.css"; 4 | import "./index.scss"; 5 | import App from "./App"; 6 | 7 | const root = document.getElementById("root"); 8 | 9 | if (import.meta.env.MODE === "production") { 10 | document.addEventListener("contextmenu", (event) => event.preventDefault()); 11 | } 12 | 13 | // biome-ignore lint/style/noNonNullAssertion: 14 | render(() => , root!); 15 | -------------------------------------------------------------------------------- /src/lazy.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from "solid-js"; 2 | 3 | export const LazyButton = lazy( 4 | () => import("alley-components/lib/components/button"), 5 | ); 6 | export const LazyRow = lazy( 7 | () => import("alley-components/lib/components/row"), 8 | ); 9 | export const LazyCol = lazy( 10 | () => import("alley-components/lib/components/col"), 11 | ); 12 | export const LazyProgress = lazy( 13 | () => import("alley-components/lib/components/progress"), 14 | ); 15 | export const LazyDropdown = lazy( 16 | () => import("alley-components/lib/components/dropdown"), 17 | ); 18 | export const LazyTooltip = lazy( 19 | () => import("alley-components/lib/components/tooltip"), 20 | ); 21 | export const LazyLink = lazy( 22 | () => import("alley-components/lib/components/link"), 23 | ); 24 | export const LazyFlex = lazy( 25 | () => import("alley-components/lib/components/flex"), 26 | ); 27 | export const LazyEmpty = lazy( 28 | () => import("alley-components/lib/components/empty"), 29 | ); 30 | export const LazyList = lazy( 31 | () => import("alley-components/lib/components/list"), 32 | ); 33 | export const LazyListItem = lazy( 34 | () => import("alley-components/lib/components/list/item"), 35 | ); 36 | export const LazyFileTypeIcon = lazy( 37 | () => import("~/components/file-type-icon"), 38 | ); 39 | export const LazyFloatButton = lazy( 40 | () => import("alley-components/lib/components/float-button/button"), 41 | ); 42 | export const LazyFloatButtonGroup = lazy( 43 | () => import("alley-components/lib/components/float-button/group"), 44 | ); 45 | export const LazySwitch = lazy( 46 | () => import("alley-components/lib/components/switch"), 47 | ); 48 | export const LazySpace = lazy( 49 | () => import("alley-components/lib/components/space"), 50 | ); 51 | export const LazyTypographyText = lazy( 52 | () => import("alley-components/lib/components/typography/text"), 53 | ); 54 | export const LazyTypographyTitle = lazy( 55 | () => import("alley-components/lib/components/typography/title"), 56 | ); 57 | export const LazyToast = lazy( 58 | () => import("alley-components/lib/components/toast"), 59 | ); 60 | export const LazyLabel = lazy( 61 | () => import("alley-components/lib/components/label"), 62 | ); 63 | export const LazyDialog = lazy( 64 | () => import("alley-components/lib/components/dialog"), 65 | ); 66 | 67 | export const LazyQrcode = lazy(() => import("~/components/qrcode")); 68 | export const LazyAboutButton = lazy(() => import("~/components/aboutButton")); 69 | 70 | export const LazySend = lazy(() => import("~/pages/send")); 71 | // export const LazySendFileList = lazy(() => import("~/pages/send/list")); 72 | 73 | export const LazyReceive = lazy(() => import("~/pages/receive")); 74 | export const LazyReceiveHeader = lazy(() => import("~/pages/receive/header")); 75 | -------------------------------------------------------------------------------- /src/pages/receive/fileListItem.tsx: -------------------------------------------------------------------------------- 1 | import { Show, useContext } from "solid-js"; 2 | import { open } from "@tauri-apps/api/shell"; 3 | import { AiFillCheckCircle } from "solid-icons/ai"; 4 | import fileType from "./fileType"; 5 | import { 6 | LazyLink, 7 | LazyListItem, 8 | LazyProgress, 9 | LazySpace, 10 | LazyTooltip, 11 | LazyTypographyText, 12 | } from "~/lazy"; 13 | import { AppContext } from "~/context"; 14 | 15 | interface FileListItemProps { 16 | index?: number; 17 | path: string; 18 | name: string; 19 | percent: number; 20 | speed?: number; 21 | size: string; 22 | } 23 | 24 | const FileListItem = (props: FileListItemProps) => { 25 | const { translations } = useContext(AppContext)!; 26 | 27 | const extension = getExtension(props.name); 28 | 29 | return ( 30 | 34 | {props.index !== undefined ? ( 35 | {props.index + 1}. 36 | ) : null} 37 | 38 | {props.name} 42 | } 43 | > 44 | 48 | open(props.path)}>{props.name} 49 | 50 | 51 | 52 | } 53 | description={ 54 | 55 | 56 | {translations()?.list_item_file_size_label}: {props.size} 57 | 58 | 59 | {translations()?.list_item_file_type_label}: {fileType(extension)} 60 | 61 | 62 | } 63 | extra={ 64 | props.speed ? ( 65 | {props.speed.toFixed(1)} MB/s 66 | ) : ( 67 | 68 | 69 | 70 | ) 71 | } 72 | foot={} 73 | /> 74 | ); 75 | }; 76 | 77 | const getExtension = (name: string): string => { 78 | const dotIndex = name.lastIndexOf("."); 79 | 80 | if (dotIndex === -1) return "UNKOWN"; 81 | 82 | return name.slice(dotIndex + 1).toUpperCase(); 83 | }; 84 | 85 | export default FileListItem; 86 | -------------------------------------------------------------------------------- /src/pages/receive/fileType.ts: -------------------------------------------------------------------------------- 1 | interface FileType { 2 | extensions: string[]; 3 | name: string; 4 | } 5 | 6 | const FILE_TYPES: FileType[] = [ 7 | { 8 | extensions: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "BMP", "SVG", "ICNS"], 9 | name: "图片", 10 | }, 11 | { 12 | extensions: ["MKV", "MP4", "AVI", "MOV"], 13 | name: "视频", 14 | }, 15 | { 16 | extensions: ["MP3", "FLAC"], 17 | name: "音频", 18 | }, 19 | { 20 | extensions: [ 21 | "DOC", 22 | "DOCX", 23 | "PPT", 24 | "PPTX", 25 | "XLS", 26 | "XLSX", 27 | "PAGES", 28 | "NUMBERS", 29 | "KEYNOTES", 30 | "MD", 31 | "PDF", 32 | ], 33 | name: "文档", 34 | }, 35 | { 36 | extensions: ["TXT", "YAML", "YML", "TOML", "INI", "JSON", "HTML"], 37 | name: "文本", 38 | }, 39 | { 40 | extensions: ["ZIP", "RAR", "7Z", "TAR"], 41 | name: "压缩文件", 42 | }, 43 | { 44 | extensions: [ 45 | "PY", 46 | "RUST", 47 | "TS", 48 | "TSX", 49 | "JS", 50 | "JSX", 51 | "SCSS", 52 | "CSS", 53 | "LESS", 54 | "JAVA", 55 | "LUA", 56 | "CJS", 57 | "CPP", 58 | "C", 59 | "GO", 60 | ], 61 | name: "代码文件", 62 | }, 63 | { 64 | extensions: ["APK", "EXE", "MSI", "DMG", "IPA", "DEB", "RPM", "APPIMAGE"], 65 | name: "应用程序", 66 | }, 67 | ]; 68 | 69 | const fileType = (extension: string) => { 70 | const ft = FILE_TYPES.find((ft) => ft.extensions.includes(extension)); 71 | 72 | return ft ? ft.name : "未知"; 73 | }; 74 | 75 | export default fileType; 76 | -------------------------------------------------------------------------------- /src/pages/receive/header.tsx: -------------------------------------------------------------------------------- 1 | import { open as pick } from "@tauri-apps/api/dialog"; 2 | import { open } from "@tauri-apps/api/shell"; 3 | import { createEffect, createSignal, onMount, useContext } from "solid-js"; 4 | import { changeDownloadsDir, getDownloadsDir, isLinux } from "~/api"; 5 | import type { MenuItemProps } from "alley-components/lib/components/dropdown"; 6 | import Loading from "alley-components/lib/components/spinner"; 7 | import { LazyCol, LazyDropdown, LazyLink, LazyRow, LazyTooltip } from "~/lazy"; 8 | import { AppContext } from "~/context"; 9 | 10 | const baseClassName = "receive-header"; 11 | 12 | const Header = () => { 13 | const { translations } = useContext(AppContext)!; 14 | 15 | const [downloadDir, setDownloadDir] = createSignal( 16 | undefined, 17 | ); 18 | const [openDropDown, setOpenDropDown] = createSignal(false); 19 | 20 | const [dropdownTop, setDownloadTop] = createSignal(40); 21 | 22 | onMount(() => { 23 | isLinux().then((f) => f && setDownloadTop(30)); 24 | }); 25 | 26 | createEffect(() => { 27 | const dir = downloadDir(); 28 | if (dir) return; 29 | 30 | getDownloadsDir().then((d) => setDownloadDir(d)); 31 | }); 32 | 33 | const pickDirectory = async () => { 34 | const dir = (await pick({ 35 | directory: true, 36 | defaultPath: downloadDir(), 37 | multiple: false, 38 | title: "选择其他目录", // https://github.com/tauri-apps/tauri/issues/6675 39 | })) as string | null; 40 | 41 | if (!dir) return; 42 | 43 | await changeDownloadsDir(dir); 44 | 45 | setDownloadDir(dir); 46 | }; 47 | 48 | const dropdownItems: MenuItemProps[] = [ 49 | { 50 | label: translations()!.receive_page_dropdown_open_button_label, 51 | onClick: () => open(downloadDir()!), 52 | }, 53 | { 54 | label: translations()!.receive_page_dropdown_pick_button_label, 55 | onClick: () => pickDirectory(), 56 | }, 57 | ]; 58 | 59 | if (!downloadDir) return ; 60 | 61 | return ( 62 | 63 | 69 | 75 | 76 | {translations()?.receive_page_directory_path_label} 77 | 78 | 79 | 80 | 81 | 87 | 91 | { 93 | setOpenDropDown(false); 94 | open(downloadDir()!); 95 | }} 96 | wrap 97 | > 98 | {downloadDir()!} 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default Header; 107 | -------------------------------------------------------------------------------- /src/pages/receive/index.scss: -------------------------------------------------------------------------------- 1 | .receive-header { 2 | margin-top: 10px; 3 | width: 100vw; 4 | padding-bottom: 5px; 5 | padding-left: 8px; 6 | box-shadow: 3px 16px 2px -15px rgba(0, 0, 0, 0.22); 7 | 8 | &-label { 9 | &-text { 10 | font-size: 0.8rem; 11 | color: var(--alley-color-weak); 12 | } 13 | } 14 | } 15 | 16 | .receive-file-list { 17 | flex-grow: 14; 18 | overflow-y: auto; 19 | max-width: 100vw; 20 | margin: 5px; 21 | padding: 0 10px; 22 | 23 | &-empty { 24 | width: 100vw; 25 | } 26 | 27 | &-item { 28 | max-width: 100vw; 29 | list-style-type: none; 30 | 31 | &:not(:first-child) { 32 | margin-top: 8px; 33 | } 34 | 35 | .row { 36 | padding: 4px 0; 37 | align-items: center; 38 | } 39 | 40 | .col { 41 | display: flex; 42 | align-items: center; 43 | min-height: 18px; 44 | } 45 | 46 | .filename { 47 | white-space: normal; 48 | word-break: break-all; 49 | justify-content: start; 50 | 51 | .label { 52 | font-weight: normal; 53 | font-size: var(--alley-font-size-sm); 54 | margin-right: 4px; 55 | color: var(--color-weak); 56 | text-shadow: 57 | 0.1px 0.1px 0.1px var(--alley-color-weak), 58 | -0.1px -0.1px 0.1px var(--alley-color-light); 59 | } 60 | } 61 | 62 | .speed, 63 | .filesize { 64 | justify-content: end; 65 | text-align: right; 66 | } 67 | 68 | .done { 69 | color: var(--alley-color-success); 70 | margin-right: 10px; 71 | font-size: var(--alley-font-size-8); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/pages/receive/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Match, 3 | Switch, 4 | createEffect, 5 | createSignal, 6 | onCleanup, 7 | onMount, 8 | Show, 9 | children, 10 | useContext, 11 | } from "solid-js"; 12 | import { appWindow } from "@tauri-apps/api/window"; 13 | import { getUploadQrCode, getQrCodeState } from "~/api"; 14 | import FileListItem from "./fileListItem"; 15 | import "./index.scss"; 16 | import { suspense } from "~/advance"; 17 | import { 18 | LazyReceiveHeader, 19 | LazyFloatButton, 20 | LazyFlex, 21 | LazyEmpty, 22 | LazyQrcode, 23 | LazyList, 24 | LazyFloatButtonGroup, 25 | LazyAboutButton, 26 | } from "~/lazy"; 27 | import { createStore } from "solid-js/store"; 28 | import { AiFillDelete, AiOutlineHome } from "solid-icons/ai"; 29 | import { AppContext } from "~/context"; 30 | 31 | const Receive = () => { 32 | const { goHome, translations } = useContext(AppContext)!; 33 | 34 | const [qrcode, setQrcode] = createSignal(null); 35 | 36 | const [taskList, setTaskList] = createStore([]); 37 | const [fileList, setFileList] = createStore[]>([]); 38 | 39 | onMount(() => { 40 | if (qrcode() || taskList.length || fileList.length) return; 41 | 42 | getUploadQrCode().then((c) => setQrcode(c)); 43 | }); 44 | 45 | createEffect(() => { 46 | const unlisten = appWindow.listen("upload://progress", (e) => { 47 | if (qrcode()) setQrcode(null); 48 | 49 | const { path, percent, aborted } = e.payload; 50 | 51 | const taskIndex = taskList.findIndex((prev) => prev.path === path); 52 | if (taskIndex === -1) { 53 | setTaskList(taskList.length, e.payload); 54 | } else { 55 | setTaskList(taskIndex, (item) => ({ 56 | ...item, 57 | percent: e.payload.percent, 58 | speed: e.payload.speed, 59 | })); 60 | } 61 | 62 | if (aborted) { 63 | setFileList((prev) => prev.filter((i) => i.path !== path)); 64 | setTaskList((prev) => prev.filter((i) => i.path !== path)); 65 | return; 66 | } 67 | 68 | if (percent === 100) { 69 | setTaskList((prev) => prev.filter((i) => i.path !== path)); 70 | 71 | const doneIndex = fileList.findIndex((prev) => prev.path === path); 72 | if (doneIndex === -1) setFileList(fileList.length, e.payload); 73 | } 74 | }); 75 | 76 | onCleanup(() => { 77 | unlisten.then((f) => f()); 78 | }); 79 | }); 80 | 81 | createEffect(() => { 82 | const code = qrcode(); 83 | if (!code) return; 84 | 85 | const timer = setInterval(async () => { 86 | const used = await getQrCodeState(code.id); 87 | 88 | if (used) { 89 | clearTimeout(timer); 90 | setQrcode(null); 91 | } 92 | }, 500); 93 | 94 | onCleanup(() => clearTimeout(timer)); 95 | }); 96 | 97 | const floatButtons = children(() => ( 98 | 99 | 100 | 101 | 102 | } 105 | onClick={() => setFileList([])} 106 | danger 107 | /> 108 | 109 | 110 | } 113 | onClick={() => { 114 | setTaskList([]); 115 | setFileList([]); 116 | goHome(); 117 | }} 118 | /> 119 | 120 | )); 121 | 122 | return ( 123 | 124 | 125 | 126 | 127 | {floatButtons()} 128 | 129 | 130 | 135 | {suspense()} 136 | 142 | 145 | 146 | 147 | 148 | {floatButtons()} 149 | 150 | 151 | 152 | {suspense()} 153 | 154 |
    155 | ( 158 | 165 | )} 166 | /> 167 | 168 | ( 171 | 178 | )} 179 | /> 180 |
181 |
182 | 183 | {floatButtons()} 184 |
185 |
186 | ); 187 | }; 188 | 189 | export default Receive; 190 | -------------------------------------------------------------------------------- /src/pages/send/index.scss: -------------------------------------------------------------------------------- 1 | .send { 2 | padding: 10px; 3 | width: 100%; 4 | 5 | &-header { 6 | height: 31px; 7 | line-height: 31px; 8 | padding-bottom: 5px; 9 | margin-bottom: 5px; 10 | width: 100%; 11 | box-shadow: 3px 16px 2px -15px rgba(0, 0, 0, 0.22); 12 | } 13 | 14 | .file-list { 15 | width: 100%; 16 | flex-grow: 14; 17 | margin-bottom: 10px; 18 | overflow-y: auto; 19 | 20 | &-wrapper { 21 | flex: 1; 22 | } 23 | 24 | .alley-list { 25 | flex: 1; 26 | } 27 | 28 | .tooltip { 29 | max-width: 250px; 30 | } 31 | 32 | &-empty { 33 | border: 1px #999 dashed; 34 | border-radius: 15px; 35 | } 36 | 37 | &:hover { 38 | border-color: var(--color-hover); 39 | } 40 | 41 | .delete-file { 42 | font-size: 20px; 43 | margin-right: 5px; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/send/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect, 3 | createSignal, 4 | onCleanup, 5 | createMemo, 6 | Show, 7 | children, 8 | useContext, 9 | } from "solid-js"; 10 | import { 11 | AiOutlineClear, 12 | AiOutlineCloseCircle, 13 | AiOutlineHome, 14 | } from "solid-icons/ai"; 15 | import { appWindow } from "@tauri-apps/api/window"; 16 | import { TauriEvent } from "@tauri-apps/api/event"; 17 | import "./index.scss"; 18 | import { getFilesMetadata, getSendFilesUrlQrCode, getQrCodeState } from "~/api"; 19 | import { deleteRepetition } from "./utils"; 20 | import { 21 | LazyAboutButton, 22 | LazyButton, 23 | LazyEmpty, 24 | LazyFileTypeIcon, 25 | LazyFlex, 26 | LazyFloatButton, 27 | LazyFloatButtonGroup, 28 | LazyLink, 29 | LazyList, 30 | LazyListItem, 31 | LazyQrcode, 32 | LazyTooltip, 33 | LazyTypographyText, 34 | } from "~/lazy"; 35 | import { addClassNames } from "alley-components/lib/utils/class"; 36 | import { open } from "@tauri-apps/api/shell"; 37 | import { AppContext } from "~/context"; 38 | 39 | const Send = () => { 40 | const { goHome, translations } = useContext(AppContext)!; 41 | 42 | const [files, setFiles] = createSignal([]); 43 | 44 | const [qrcode, setQrcode] = createSignal(null); 45 | 46 | createEffect(() => { 47 | const unlisten = appWindow.listen( 48 | TauriEvent.WINDOW_FILE_DROP, 49 | async (e) => { 50 | const paths = deleteRepetition(e.payload, files()); 51 | const sendFiles = await getFilesMetadata(paths); 52 | 53 | setFiles((pre) => [...pre, ...sendFiles]); 54 | }, 55 | ); 56 | 57 | onCleanup(() => { 58 | unlisten.then((f) => f()); 59 | }); 60 | }); 61 | 62 | createEffect(() => { 63 | const code = qrcode(); 64 | if (!code) return; 65 | 66 | const timer = setInterval(async () => { 67 | const used = await getQrCodeState(code.id); 68 | 69 | if (used) { 70 | clearTimeout(timer); 71 | setQrcode(null); 72 | } 73 | }, 500); 74 | 75 | onCleanup(() => { 76 | clearTimeout(timer); 77 | location.reload(); 78 | }); 79 | }); 80 | 81 | const removeFile = (path: string) => 82 | setFiles((pre) => pre.filter((f) => f.path !== path)); 83 | 84 | const newSendFilesQrCode = async () => { 85 | const code = await getSendFilesUrlQrCode(files()); 86 | setQrcode(code); 87 | }; 88 | 89 | const isEmpty = createMemo(() => files().length === 0); 90 | const filesPostion = () => (isEmpty() ? "center" : "start"); 91 | 92 | const floatButtons = children(() => ( 93 | 94 | 95 | 96 | 97 | } 99 | onClick={() => setFiles([])} 100 | danger 101 | tooltip={translations()?.clear_button_text} 102 | /> 103 | 104 | 105 | } 108 | onClick={goHome} 109 | /> 110 | 111 | )); 112 | 113 | return ( 114 | <> 115 | }> 116 | 122 |
{translations()?.send_page_title}
123 | 124 | 132 | {files().length ? ( 133 | 134 | ( 137 | 144 | open(file.path)}> 145 | {file.name} 146 | 147 | 148 | } 149 | description={ 150 | <> 151 | 152 | {translations()?.list_item_file_size_label}:{" "} 153 | {file.size} 154 | 155 |      156 | 157 | {translations()?.list_item_file_type_label}:{" "} 158 | {file.extension} 159 | 160 | 161 | } 162 | extra={[ 163 | removeFile(file.path)} 169 | > 170 | 171 | , 172 | ]} 173 | /> 174 | )} 175 | /> 176 | 177 | 178 | {translations()?.send_page_drop_description} 179 | 180 | 181 | ) : ( 182 | 185 | )} 186 | 187 | 188 | 194 | {translations()?.ok_button_text} 195 | 196 |
197 |
198 | 199 | {floatButtons()} 200 | 201 | ); 202 | }; 203 | 204 | export default Send; 205 | -------------------------------------------------------------------------------- /src/pages/send/utils.ts: -------------------------------------------------------------------------------- 1 | export const deleteRepetition = ( 2 | paths: string[], 3 | files: SendFile[], 4 | ): string[] => { 5 | return paths.filter((p) => { 6 | const index = files?.findIndex((f) => f.path === p); 7 | return index === undefined ? true : index === -1; 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # 手机端页面 2 | 3 | 手机浏览器访问的页面,未来可能也会是手机客户端的页面。 4 | -------------------------------------------------------------------------------- /static/bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | registry = "https://registry.npmmirror.com" 3 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 小路速传 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "solid-icons": "^1.1.0", 14 | "solid-js": "^1.9.3" 15 | }, 16 | "devDependencies": { 17 | "sass": "^1.83.0", 18 | "typescript": "^5.7.2", 19 | "vite": "^5.4.11", 20 | "vite-plugin-solid": "^2.11.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /static/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/static/public/logo.png -------------------------------------------------------------------------------- /static/src/App.scss: -------------------------------------------------------------------------------- 1 | #root { 2 | // width: 100vw; 3 | height: 100vh; 4 | margin: 0 auto; 5 | display: flex; 6 | 7 | .container { 8 | height: 100vh; 9 | width: 100vw; 10 | display: flex; 11 | flex-direction: column; 12 | 13 | .header { 14 | margin-top: 10px; 15 | height: 30px; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | color: var(--color-weak); 20 | width: 100vw; 21 | flex: 1; 22 | } 23 | 24 | .content { 25 | flex-grow: 10; 26 | display: flex; 27 | } 28 | } 29 | 30 | .result, 31 | .align-center { 32 | align-items: center; 33 | } 34 | } 35 | 36 | .filename { 37 | .label { 38 | font-weight: normal; 39 | font-size: var(--font-size-4); 40 | margin-right: 4px; 41 | color: var(--color-weak); 42 | text-shadow: 43 | 0.1px 0.1px 0.1px var(--color-weak), 44 | -0.1px -0.1px 0.1px var(--color-light); 45 | } 46 | } 47 | 48 | .dark-switch { 49 | position: fixed; 50 | right: 20px; 51 | top: 10px; 52 | z-index: var(--z-index-level-top); 53 | } 54 | -------------------------------------------------------------------------------- /static/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch, createEffect, createSignal, onMount } from "solid-js"; 2 | import { BiRegularSun, BiSolidMoon } from "solid-icons/bi"; 3 | import Result from "~/components/result"; 4 | import Send from "~/pages/send"; 5 | import Receive from "~/pages/receive"; 6 | import SwitchDark from "~/components/switch"; 7 | import "~/App.scss"; 8 | import { getLocale } from "./i18n"; 9 | import LocaleContext from "./context"; 10 | 11 | type Mode = "receive" | "send"; 12 | 13 | const App = () => { 14 | const href = window.location.href; 15 | const url = new URL(href); 16 | const params = new URLSearchParams(url.search); 17 | const mode = params.get("mode") as Mode | null; 18 | 19 | const locale = getLocale(); 20 | 21 | const [isDark, setIsDark] = createSignal(false); 22 | 23 | onMount(() => { 24 | // 设置默认主题色 25 | if (matchMedia("(prefers-color-scheme: dark)").matches) { 26 | setIsDark(true); 27 | } else { 28 | setIsDark(false); 29 | } 30 | 31 | // 监听系统颜色切换 32 | window 33 | .matchMedia("(prefers-color-scheme: dark)") 34 | .addEventListener("change", (event) => { 35 | if (event.matches) { 36 | setIsDark(true); 37 | } else { 38 | setIsDark(false); 39 | } 40 | }); 41 | }); 42 | 43 | // 手动切换主题色 44 | createEffect(() => { 45 | if (isDark()) window.document.documentElement.setAttribute("class", "dark"); 46 | else window.document.documentElement.removeAttribute("class"); 47 | }); 48 | 49 | return ( 50 | 51 | { 55 | setIsDark((pre) => { 56 | return !pre; 57 | }); 58 | }} 59 | uncheckedChild={} 60 | checkedChild={} 61 | /> 62 | 63 | 64 | 65 |
66 | 72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 |
84 | ); 85 | }; 86 | 87 | export default App; 88 | -------------------------------------------------------------------------------- /static/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/src/components/button/index.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | border-radius: 8px; 3 | border: 1px solid transparent; 4 | padding: 0.6em 1.2em; 5 | font-size: 1em; 6 | font-weight: 500; 7 | font-family: inherit; 8 | background-color: var(--color-primary); 9 | cursor: pointer; 10 | transition: border-color 0.25s; 11 | color: var(--color-button-text); 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | 16 | &-disabled { 17 | cursor: not-allowed; 18 | color: var(--color-button-disabled-text); 19 | background-color: var(--color-button-disabled-background); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /static/src/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | 5 | interface ButtonProps { 6 | class?: string; 7 | children?: JSXElement; 8 | block?: boolean; 9 | disabled?: boolean; 10 | onClick?: () => void; 11 | } 12 | 13 | const baseClassName = "button"; 14 | 15 | const Button = (props: ButtonProps) => { 16 | const classNames = () => 17 | addClassNames( 18 | baseClassName, 19 | props.class, 20 | props.disabled ? `${baseClassName}-disabled` : undefined, 21 | ); 22 | 23 | return ( 24 | 31 | ); 32 | }; 33 | 34 | export default Button; 35 | -------------------------------------------------------------------------------- /static/src/components/card/index.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/static/src/components/card/index.scss -------------------------------------------------------------------------------- /static/src/components/card/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import Divider from "../divider"; 4 | 5 | interface CardProps { 6 | class?: string; 7 | style?: CSSProperties; 8 | title: JSXElement; 9 | description?: JSXElement; 10 | actions: JSXElement; 11 | } 12 | 13 | const baseClassName = "card"; 14 | 15 | const Card = (props: CardProps) => { 16 | const classNames = () => addClassNames(baseClassName, props.class); 17 | 18 | return ( 19 |
20 |
{props.title}
21 | 22 |
{props.description}
23 |
{props.actions}
24 |
25 | ); 26 | }; 27 | 28 | export default Card; 29 | -------------------------------------------------------------------------------- /static/src/components/divider/index.scss: -------------------------------------------------------------------------------- 1 | $base: ".divider"; 2 | 3 | #{$base} { 4 | &-horizontal { 5 | display: flex; 6 | align-items: center; 7 | margin: 16px 0; 8 | border: 0 solid var(--color-border); 9 | color: var(--color-weak); 10 | font-size: 14px; 11 | 12 | &:before, 13 | &:after { 14 | flex: auto; 15 | display: block; 16 | content: ""; 17 | border-style: inherit; 18 | border-color: inherit; 19 | border-width: 1px 0 0; 20 | } 21 | 22 | #{$base}-content { 23 | flex: none; 24 | padding: 0 16px; 25 | } 26 | } 27 | 28 | #{$base}-left#{$base}-horizontal:before { 29 | max-width: 10%; 30 | } 31 | 32 | #{$base}-right#{$base}-horizontal:after { 33 | max-width: 10%; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /static/src/components/divider/index.tsx: -------------------------------------------------------------------------------- 1 | import { mergeProps, type JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | 5 | interface DividerProps { 6 | class?: string; 7 | children?: JSXElement; 8 | direction?: "horizontal" | "vertical"; 9 | contentPosition?: "center" | "left" | "right"; 10 | } 11 | 12 | const baseClassName = "divider"; 13 | 14 | const Divider = (props: DividerProps) => { 15 | const merged = mergeProps( 16 | { direction: "horizontal", contentPosition: "center" }, 17 | props, 18 | ); 19 | 20 | const classNames = () => 21 | addClassNames( 22 | baseClassName, 23 | merged.class, 24 | `${baseClassName}-${merged.direction}`, 25 | `${baseClassName}-${merged.contentPosition}`, 26 | ); 27 | 28 | return
{merged.children}
; 29 | }; 30 | 31 | export default Divider; 32 | -------------------------------------------------------------------------------- /static/src/components/error-block/icons/empty.tsx: -------------------------------------------------------------------------------- 1 | const EmptyIcon = () => ( 2 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 41 | 42 | 46 | 53 | 58 | 59 | 63 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 80 | 81 | 82 | 83 | ); 84 | 85 | export default EmptyIcon; 86 | -------------------------------------------------------------------------------- /static/src/components/error-block/index.scss: -------------------------------------------------------------------------------- 1 | .error-block { 2 | --color: var(--color-text); 3 | --image-height: var(--error-block-image-height, 100px); 4 | --image-height-full-page: var(--error-block-image-height-full-page, 200px); 5 | --image-width: var(--error-block-image-width, auto); 6 | --image-width-full-page: var(--error-block-image-width-full-page, auto); 7 | box-sizing: border-box; 8 | text-align: center; 9 | 10 | &-full-page { 11 | padding-top: calc(50vh - var(--image-height-full-page)); 12 | 13 | &-image { 14 | height: var(--image-height-full-page); 15 | width: var(--image-width-full-page); 16 | } 17 | 18 | &-description { 19 | margin-top: 20px; 20 | font-size: var(--font-size-main); 21 | 22 | &-title { 23 | font-size: 20px; 24 | color: var(--color-text); 25 | } 26 | } 27 | } 28 | 29 | &-image { 30 | height: var(--image-height); 31 | width: var(--image-width); 32 | max-width: 100%; 33 | 34 | svg { 35 | height: 100%; 36 | } 37 | } 38 | 39 | &-description { 40 | font-size: var(--font-size-4); 41 | color: var(--color-weak); 42 | line-height: 1.4; 43 | margin-top: 12px; 44 | 45 | &-title { 46 | font-size: var(--font-size-7); 47 | } 48 | 49 | &-subtitle { 50 | margin-top: 8px; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /static/src/components/error-block/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.scss"; 2 | import EmptyIcon from "./icons/empty"; 3 | import type { JSXElement } from "solid-js"; 4 | import { addClassNames } from "../utils"; 5 | 6 | interface ErrorBlockProps { 7 | class?: string; 8 | description?: string; 9 | fullPage?: boolean; 10 | title?: string; 11 | status: "empty"; 12 | } 13 | 14 | const baseClassName = "error-block"; 15 | 16 | const ErrorBlock = (props: ErrorBlockProps) => { 17 | const classNames = () => addClassNames(baseClassName, props.class); 18 | 19 | const icons: Record = { 20 | empty: , 21 | }; 22 | 23 | return ( 24 |
25 |
{icons[props.status]}
26 |
27 |
{props.title}
28 |
29 | {props.description} 30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default ErrorBlock; 37 | -------------------------------------------------------------------------------- /static/src/components/grid/index.scss: -------------------------------------------------------------------------------- 1 | .grid { 2 | --gap-horizontal: var(--gap); 3 | --gap-vertical: var(--gap); 4 | display: grid; 5 | column-gap: var(--gap-horizontal); 6 | row-gap: var(--gap-vertical); 7 | grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); 8 | align-items: stretch; 9 | color: var(--color-text); 10 | 11 | &-item { 12 | grid-column-end: span var(--item-span); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /static/src/components/grid/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | import GridItem from "./item"; 5 | 6 | interface GridProps { 7 | class?: string; 8 | style?: CSSProperties; 9 | columns: number; 10 | gap?: number; 11 | children: JSXElement; 12 | } 13 | 14 | const baseClassName = "grid"; 15 | 16 | const Grid = (props: GridProps) => { 17 | const classNames = () => addClassNames(baseClassName, props.class || ""); 18 | 19 | const style: () => CSSProperties = () => ({ 20 | "--columns": props.columns, 21 | "--gap": `${props.gap || 8}px`, 22 | }); 23 | 24 | return ( 25 |
26 | {props.children} 27 |
28 | ); 29 | }; 30 | 31 | Grid.Item = GridItem; 32 | 33 | export default Grid; 34 | -------------------------------------------------------------------------------- /static/src/components/grid/item.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | 4 | interface GridItemProps { 5 | class?: string; 6 | span: number; 7 | children: JSXElement; 8 | } 9 | 10 | const baseClassName = "grid-item"; 11 | 12 | const GridItem = (props: GridItemProps) => { 13 | const classNames = () => addClassNames(baseClassName, props.class || ""); 14 | 15 | const style: () => CSSProperties = () => ({ 16 | "--item-span": props.span, 17 | }); 18 | 19 | return ( 20 |
21 | {props.children} 22 |
23 | ); 24 | }; 25 | 26 | export default GridItem; 27 | -------------------------------------------------------------------------------- /static/src/components/link/index.scss: -------------------------------------------------------------------------------- 1 | .link { 2 | color: var(--color-primary); 3 | cursor: pointer; 4 | font-family: monospace; 5 | text-decoration: none; 6 | } 7 | -------------------------------------------------------------------------------- /static/src/components/link/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js"; 2 | import "./index.scss"; 3 | 4 | interface HrefLinkProps { 5 | href: string; 6 | download?: string; 7 | } 8 | 9 | interface ButtonLinkProps { 10 | onClick: () => void; 11 | } 12 | 13 | interface BaseLinkProps { 14 | class?: string; 15 | children: JSXElement; 16 | } 17 | 18 | type LinkProps = 19 | | (BaseLinkProps & HrefLinkProps) 20 | | (BaseLinkProps & ButtonLinkProps); 21 | 22 | const baseClassName = "link"; 23 | 24 | const Link = (props: LinkProps) => { 25 | return ( 26 | 32 | {props.children} 33 | 34 | ); 35 | }; 36 | 37 | export default Link; 38 | -------------------------------------------------------------------------------- /static/src/components/list/index.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | 6 | &-header { 7 | text-align: center; 8 | color: var(--color-weak); 9 | font-size: var(--font-size-5); 10 | flex: 1; 11 | } 12 | 13 | &-items { 14 | flex: 28; 15 | padding: 0 10px; 16 | overflow-y: auto; 17 | 18 | &-item { 19 | width: 100%; 20 | list-style-type: none; 21 | display: flex; 22 | flex-direction: row; 23 | padding: 8px 0; 24 | box-shadow: 0 1px 7px 0 var(--color-shadow); 25 | border-radius: var(--border-radius); 26 | margin: 10px 0; 27 | flex-wrap: wrap; 28 | background-color: var(--color-background); 29 | 30 | &__content { 31 | display: flex; 32 | flex-direction: row; 33 | width: 100%; 34 | 35 | &-avatar { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | font-size: 24px; 40 | } 41 | 42 | &-texts { 43 | padding: 0 5px; 44 | flex-grow: 8; 45 | text-align: left; 46 | } 47 | 48 | &-title { 49 | font-size: 1rem; 50 | line-height: calc(1rem + 4px); 51 | font-weight: bold; 52 | word-wrap: break-word; 53 | word-break: break-all; 54 | } 55 | 56 | &-description { 57 | font-size: var(--font-size-3); 58 | color: var(--color-weak); 59 | } 60 | 61 | &-extra { 62 | display: flex; 63 | justify-content: center; 64 | align-items: center; 65 | font-size: var(--font-size-3); 66 | color: var(--color-weak); 67 | } 68 | } 69 | 70 | &__foot { 71 | margin-top: 4px; 72 | width: 100%; 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /static/src/components/list/index.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show, type JSXElement } from "solid-js"; 2 | import "./index.scss"; 3 | import { addClassNames } from "../utils"; 4 | 5 | interface ListItemProps { 6 | class?: string; 7 | avatar?: JSXElement; 8 | title: JSXElement; 9 | description?: JSXElement; 10 | extra: JSXElement; 11 | foot?: JSXElement; 12 | } 13 | 14 | const ListItem = (props: ListItemProps) => { 15 | const classPrefix = "list-items-item"; 16 | const classNames = () => addClassNames(classPrefix, props.class); 17 | 18 | return ( 19 |
  • 20 |
    21 | 22 |
    {props.avatar}
    23 |
    24 | 25 |
    26 |
    {props.title}
    27 |
    28 | {props.description} 29 |
    30 |
    31 | 32 |
    33 | {props.extra ? props.extra : null} 34 |
    35 |
    36 | 37 | 38 |
    {props.foot}
    39 |
    40 |
  • 41 | ); 42 | }; 43 | 44 | interface ListProps { 45 | header?: JSXElement; 46 | class?: string; 47 | dataSource: T[]; 48 | renderItem: (item: T, index: () => number) => JSXElement; 49 | } 50 | 51 | const baseClassName = "list"; 52 | 53 | const List = (props: ListProps) => { 54 | const classNames = () => addClassNames(baseClassName, props.class); 55 | 56 | return ( 57 |
    58 | 59 |
    {props.header}
    60 |
    61 | 62 |
      63 | 64 | {(item, index) => props.renderItem(item, index)} 65 | 66 |
    67 |
    68 | ); 69 | }; 70 | 71 | List.Item = ListItem; 72 | 73 | export default List; 74 | -------------------------------------------------------------------------------- /static/src/components/loading/dot/icon.tsx: -------------------------------------------------------------------------------- 1 | const DotLoadingIcon = () => ( 2 | 3 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 30 | 31 | 32 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | 50 | export default DotLoadingIcon; 51 | -------------------------------------------------------------------------------- /static/src/components/loading/dot/index.scss: -------------------------------------------------------------------------------- 1 | .dot-loading { 2 | display: inline-block; 3 | } 4 | -------------------------------------------------------------------------------- /static/src/components/loading/dot/index.tsx: -------------------------------------------------------------------------------- 1 | import { addClassNames } from "~/components/utils"; 2 | import "./index.scss"; 3 | import DotLoadingIcon from "./icon"; 4 | 5 | interface DotLoadingProps { 6 | class?: string; 7 | color?: "default" | "primary" | "white" | string; 8 | } 9 | 10 | const baseClassName = "dot-loading"; 11 | 12 | const DotLoading = (props: DotLoadingProps) => { 13 | const classNames = () => addClassNames(baseClassName, props.class || ""); 14 | 15 | const style = (): CSSProperties => ({ 16 | color: 17 | props.color && props.color !== "default" 18 | ? props.color === "primary" 19 | ? "var(--color-primary)" 20 | : props.color === "white" 21 | ? "#fff" 22 | : props.color 23 | : "var(--color-weak)", 24 | }); 25 | 26 | return ( 27 |
    28 | 29 |
    30 | ); 31 | }; 32 | 33 | export default DotLoading; 34 | -------------------------------------------------------------------------------- /static/src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import DotLoading from "./dot"; 2 | import SpinLoading from "./spin"; 3 | 4 | export default { DotLoading, SpinLoading }; 5 | -------------------------------------------------------------------------------- /static/src/components/loading/spin/index.scss: -------------------------------------------------------------------------------- 1 | .spin-loading { 2 | --spin-content-height: 400px; 3 | --spin-dot-size: 20px; 4 | --spin-dot-size-sm: 14px; 5 | --spin-dot-size-lg: 32px; 6 | position: static; 7 | display: inline-block; 8 | opacity: 1; 9 | box-sizing: border-box; 10 | margin: 0; 11 | padding: 0; 12 | color: var(--ant-color-primary); 13 | font-size: 0; 14 | line-height: var(--ant-line-height); 15 | list-style: none; 16 | font-family: var(--ant-font-family); 17 | text-align: center; 18 | vertical-align: middle; 19 | transition: transform var(--motion-duration-slow) var(--motion-ease-in-out-circ); 20 | 21 | &-dot { 22 | position: relative; 23 | display: inline-block; 24 | font-size: var(--spin-dot-size); 25 | width: 1em; 26 | height: 1em; 27 | } 28 | 29 | &-dot-spin { 30 | transform: rotate(45deg); 31 | animation-name: antRotate; 32 | animation-duration: 1.2s; 33 | animation-iteration-count: infinite; 34 | animation-timing-function: linear; 35 | } 36 | 37 | &-dot-item { 38 | position: absolute; 39 | display: block; 40 | width: calc((var(--ant-spin-dot-size) - var(--ant-margin-xxs) / 2) / 2); 41 | height: calc((var(--ant-spin-dot-size) - var(--ant-margin-xxs) / 2) / 2); 42 | background-color: var(--ant-color-primary); 43 | border-radius: 100%; 44 | transform: scale(0.75); 45 | transform-origin: 50% 50%; 46 | opacity: 0.3; 47 | animation-name: antSpinMove; 48 | animation-duration: 1s; 49 | animation-iteration-count: infinite; 50 | animation-timing-function: linear; 51 | animation-direction: alternate; 52 | 53 | &:nth-child(1) { 54 | top: 0; 55 | inset-inline-start: 0; 56 | animation-delay: 0s; 57 | } 58 | 59 | &:nth-child(2) { 60 | top: 0; 61 | inset-inline-end: 0; 62 | animation-delay: 0.4s; 63 | } 64 | 65 | &:nth-child(3) { 66 | inset-inline-end: 0; 67 | bottom: 0; 68 | animation-delay: 0.8s; 69 | } 70 | 71 | &:nth-child(4) { 72 | bottom: 0; 73 | inset-inline-start: 0; 74 | animation-delay: 1.2s; 75 | } 76 | } 77 | } 78 | 79 | @keyframes antSpinMove { 80 | to { 81 | opacity: 1; 82 | } 83 | } 84 | 85 | @keyframes antRotate { 86 | to { 87 | transform: rotate(405deg); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /static/src/components/loading/spin/index.tsx: -------------------------------------------------------------------------------- 1 | import { addClassNames } from "~/components/utils"; 2 | import "./index.scss"; 3 | 4 | interface SpinLoadingProps { 5 | class?: string; 6 | color?: "default" | "primary" | "white" | string; 7 | } 8 | 9 | const baseClassName = "spin-loading"; 10 | 11 | const SpinLoading = (props: SpinLoadingProps) => { 12 | const classNames = () => addClassNames(baseClassName, props.class || ""); 13 | 14 | const style = (): CSSProperties => ({ 15 | color: 16 | props.color && props.color !== "default" 17 | ? props.color === "primary" 18 | ? "var(--color-primary)" 19 | : props.color === "white" 20 | ? "#fff" 21 | : props.color 22 | : "var(--color-weak)", 23 | }); 24 | 25 | return ( 26 |
    27 | 28 | 29 | 30 | 31 | 32 | 33 |
    34 | ); 35 | }; 36 | 37 | export default SpinLoading; 38 | -------------------------------------------------------------------------------- /static/src/components/progress/index.scss: -------------------------------------------------------------------------------- 1 | .progress { 2 | --border-radius: 2px; 3 | --fill-color: var(--color-primary); 4 | width: calc(100% - 10px); 5 | height: 4px; 6 | 7 | &-float { 8 | position: absolute; 9 | z-index: -1; 10 | } 11 | 12 | &-trail { 13 | flex: auto; 14 | height: 100%; 15 | border-radius: 15px; 16 | } 17 | 18 | &-fill { 19 | transition: width 0.3s; 20 | background: var(--fill-color); 21 | height: 100%; 22 | position: relative; 23 | border-radius: var(--border-radius); 24 | 25 | &::after { 26 | content: attr(data-percent) "%"; 27 | color: var(--color-progress-text); 28 | position: absolute; 29 | right: 0; 30 | top: -5px; 31 | z-index: 99; 32 | padding: 0 2px; 33 | background-color: var(--color-primary); 34 | border-radius: 5px; 35 | font-size: var(--font-size-1); 36 | } 37 | } 38 | 39 | &-fill-success { 40 | border-radius: var(--border-radius); 41 | background-color: var(--color-success); 42 | height: 100%; 43 | transition: width 0.3s; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /static/src/components/progress/index.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | 5 | interface ProgressProps { 6 | class?: string; 7 | percent?: number; 8 | float?: boolean; 9 | } 10 | 11 | const baseClassName = "progress"; 12 | 13 | const Progress = (props: ProgressProps) => { 14 | const classNames = () => 15 | addClassNames( 16 | baseClassName, 17 | props.class, 18 | props.float ? `${baseClassName}-float` : undefined, 19 | ); 20 | 21 | const fillStyle = (): CSSProperties => ({ 22 | width: props.percent ? `${props.percent}%` : 0, 23 | }); 24 | 25 | return ( 26 |
    27 |
    28 | 29 |
    38 | 39 |
    40 |
    41 | ); 42 | }; 43 | 44 | export default Progress; 45 | -------------------------------------------------------------------------------- /static/src/components/result/index.scss: -------------------------------------------------------------------------------- 1 | $base: ".result"; 2 | 3 | #{$base} { 4 | padding: 32px 12px; 5 | background-color: var(--color-background); 6 | 7 | &-icon { 8 | font-size: 52px; 9 | box-sizing: border-box; 10 | width: 64px; 11 | height: 64px; 12 | margin: 0 auto 20px; 13 | padding: 6px; 14 | } 15 | 16 | &-title { 17 | color: var(--color-text); 18 | font-size: var(--font-size-10); 19 | line-height: 1.4; 20 | text-align: center; 21 | } 22 | 23 | &-description { 24 | margin-top: 8px; 25 | color: var(--color-weak); 26 | font-size: var(--font-size-7); 27 | line-height: 1.4; 28 | text-align: center; 29 | } 30 | 31 | &-full-screen { 32 | padding: 0; 33 | margin: 0; 34 | // background-color: red; 35 | z-index: 1; 36 | position: fixed; 37 | top: 0; 38 | left: 0; 39 | width: 100vw; 40 | height: 100vh; 41 | display: flex; 42 | flex-direction: column; 43 | justify-content: center; 44 | align-items: center; 45 | } 46 | } 47 | 48 | #{$base}-error { 49 | #{$base}-icon { 50 | color: var(--color-danger); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /static/src/components/result/index.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, Match } from "solid-js"; 2 | import { 3 | AiFillCheckCircle, 4 | AiFillCloseCircle, 5 | AiFillInfoCircle, 6 | } from "solid-icons/ai"; 7 | import "./index.scss"; 8 | import { addClassNames } from "../utils"; 9 | 10 | interface ResultProps { 11 | class?: string; 12 | style?: CSSProperties; 13 | status: "info" | "error" | "success"; 14 | title: string; 15 | description?: string; 16 | fullScreen?: boolean; 17 | } 18 | 19 | const baseClassName = "result"; 20 | 21 | const Result = (props: ResultProps) => { 22 | const classNames = () => 23 | addClassNames( 24 | baseClassName, 25 | `${baseClassName}-${props.status}`, 26 | props.fullScreen && `${baseClassName}-full-screen`, 27 | props.class, 28 | ); 29 | 30 | return ( 31 |
    32 |
    33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
    47 |
    {props.title}
    48 |
    {props.description}
    49 |
    50 | ); 51 | }; 52 | 53 | export default Result; 54 | -------------------------------------------------------------------------------- /static/src/components/space/index.scss: -------------------------------------------------------------------------------- 1 | $base: ".space"; 2 | 3 | #{$base} { 4 | display: inline-flex; 5 | --gap-vertical: var(--gap); 6 | --gap-horizontal: var(--gap); 7 | 8 | &#{$base}-block { 9 | display: flex; 10 | } 11 | 12 | &-justify-start { 13 | justify-content: flex-start; 14 | } 15 | 16 | &-justify-end { 17 | justify-content: flex-end; 18 | } 19 | 20 | &-justify-center { 21 | justify-content: center; 22 | } 23 | 24 | &-justify-between { 25 | justify-content: space-between; 26 | } 27 | 28 | &-justify-around { 29 | justify-content: space-around; 30 | } 31 | 32 | &-justify-evenly { 33 | justify-content: space-evenly; 34 | } 35 | 36 | &-justify-stretch { 37 | justify-content: stretch; 38 | } 39 | 40 | &-align-start { 41 | align-items: flex-start; 42 | } 43 | 44 | &-align-end { 45 | align-items: flex-end; 46 | } 47 | 48 | &-align-center { 49 | align-items: center; 50 | } 51 | 52 | &-align-baseline { 53 | align-items: baseline; 54 | } 55 | 56 | &-horizontal { 57 | flex-direction: row; 58 | 59 | >#{$base}-item { 60 | margin-right: var(--gap-horizontal); 61 | 62 | &:last-child { 63 | margin-right: 0; 64 | } 65 | } 66 | } 67 | 68 | &-vertical { 69 | flex-direction: column; 70 | 71 | >#{$base}-item { 72 | margin-bottom: var(--gap-vertical); 73 | 74 | &:last-child { 75 | margin-bottom: 0; 76 | } 77 | } 78 | } 79 | 80 | &-wrap { 81 | flex-wrap: wrap; 82 | margin-bottom: calc(var(--gap-vertical) * -1); 83 | } 84 | 85 | #{$base}-item { 86 | flex: none; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /static/src/components/space/index.tsx: -------------------------------------------------------------------------------- 1 | import { For, type JSXElement } from "solid-js"; 2 | import { addClassNames } from "~/components/utils"; 3 | import "./index.scss"; 4 | 5 | interface SpaceProps { 6 | class?: string; 7 | children: JSXElement; 8 | wrap?: boolean; 9 | direction?: "vertical" | "horizontal"; 10 | gap?: number | string; 11 | block?: boolean; 12 | onClick?: () => void; 13 | align?: "start" | "end" | "center" | "baseline"; 14 | justify?: 15 | | "start" 16 | | "end" 17 | | "center" 18 | | "between" 19 | | "around" 20 | | "evenly" 21 | | "stretch"; 22 | } 23 | 24 | const baseClassName = "space"; 25 | 26 | const Space = (props: SpaceProps) => { 27 | const classNames = () => 28 | addClassNames( 29 | baseClassName, 30 | props.class, 31 | props.wrap ? `${baseClassName}-wrap` : undefined, 32 | `${baseClassName}-${props.direction || "horizontal"}`, 33 | props.block ? `${baseClassName}-block` : undefined, 34 | props.align ? `${baseClassName}-align-${props.align}` : undefined, 35 | props.justify ? `${baseClassName}-justify-${props.justify}` : undefined, 36 | ); 37 | 38 | const style = (): CSSProperties => ({ 39 | "--gap": props.gap 40 | ? typeof props.gap === "number" 41 | ? `${props.gap}px` 42 | : props.gap 43 | : 0, 44 | }); 45 | 46 | return ( 47 |
    48 | 51 | {(item) =>
    {item}
    } 52 |
    53 |
    54 | ); 55 | }; 56 | 57 | export default Space; 58 | -------------------------------------------------------------------------------- /static/src/components/switch/index.scss: -------------------------------------------------------------------------------- 1 | $base: "switch"; 2 | 3 | .#{$base} { 4 | --checked-color: var(--color-primary); 5 | --height: 31px; 6 | --width: 51px; 7 | --border-width: 2px; 8 | 9 | display: inline-block; 10 | vertical-align: middle; 11 | box-sizing: border-box; 12 | position: relative; 13 | align-self: center; 14 | cursor: pointer; 15 | 16 | input { 17 | display: none; 18 | } 19 | 20 | &-checkbox { 21 | min-width: var(--width); 22 | height: var(--height); 23 | box-sizing: border-box; 24 | border-radius: 31px; 25 | background: var(--color-border); 26 | z-index: 0; 27 | overflow: hidden; 28 | line-height: var(--height); 29 | 30 | &:before { 31 | content: " "; 32 | position: absolute; 33 | left: var(--border-width); 34 | top: var(--border-width); 35 | width: calc(100% - 2 * var(--border-width)); 36 | height: calc(var(--height) - 2 * var(--border-width)); 37 | border-radius: calc(var(--height) - 2 * var(--border-width)); 38 | box-sizing: border-box; 39 | background: var(--color-background); 40 | z-index: 1; 41 | transition: all 200ms; 42 | transform: scale(1); 43 | } 44 | } 45 | 46 | &-inner { 47 | position: relative; 48 | z-index: 1; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | margin: 0 8px 0 calc(var(--height) - var(--border-width) + 5px); 53 | height: 100%; 54 | color: var(--color-weak); 55 | transition: margin 200ms; 56 | font-size: var(--font-size-7); 57 | } 58 | 59 | /* 选中状态 */ 60 | &.#{$base}-checked { 61 | .#{$base}-checkbox { 62 | background: var(--checked-color); 63 | 64 | &:before { 65 | transform: scale(0); 66 | } 67 | } 68 | 69 | .#{$base}-handle { 70 | left: calc(100% - (var(--height) - var(--border-width))); 71 | } 72 | 73 | .#{$base}-inner { 74 | margin: 0 calc(var(--height) - var(--border-width) + 5px) 0 8px; 75 | color: var(--color-text-light-solid); 76 | } 77 | } 78 | 79 | /* 禁用状态 */ 80 | &.#{$base}-disabled { 81 | cursor: not-allowed; 82 | opacity: 0.4; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /static/src/components/switch/index.tsx: -------------------------------------------------------------------------------- 1 | import { JSXElement } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | 5 | interface SwitchProps { 6 | class?: string; 7 | checked?: boolean; 8 | setChecked: () => void; 9 | disabled?: boolean; 10 | checkedChild?: JSXElement; 11 | uncheckedChild?: JSXElement; 12 | } 13 | 14 | const baseClassName = "switch"; 15 | 16 | const Switch = (props: SwitchProps) => { 17 | const classNames = () => 18 | addClassNames( 19 | baseClassName, 20 | props.class, 21 | props.checked ? `${baseClassName}-checked` : undefined, 22 | props.disabled ? "${baseClassName}-disabled" : undefined, 23 | ); 24 | 25 | const onClick = () => { 26 | if (props.disabled) return; 27 | 28 | props.setChecked(); 29 | }; 30 | 31 | return ( 32 |
    33 |
    34 |
    35 | {props.checked ? props.checkedChild : props.uncheckedChild} 36 |
    37 |
    38 |
    39 | ); 40 | }; 41 | 42 | export default Switch; 43 | -------------------------------------------------------------------------------- /static/src/components/toast/index.scss: -------------------------------------------------------------------------------- 1 | .toast { 2 | position: fixed; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | padding: 10px; 7 | background-color: #3333338f; 8 | color: #fff; 9 | border-radius: 5px; 10 | } 11 | -------------------------------------------------------------------------------- /static/src/components/toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onCleanup, createEffect } from "solid-js"; 2 | import { addClassNames } from "../utils"; 3 | import "./index.scss"; 4 | 5 | interface ToastProps { 6 | class?: string; 7 | duration?: number; 8 | message: string; 9 | } 10 | 11 | const baseClassName = "toast"; 12 | 13 | const Toast = (props: ToastProps) => { 14 | const [visible, setVisible] = createSignal(true); 15 | 16 | const classNames = () => addClassNames(baseClassName, props.class); 17 | 18 | createEffect(() => { 19 | const timeout = setTimeout(() => { 20 | setVisible(false); 21 | }, props.duration || 1000); // 默认 1 秒 22 | 23 | onCleanup(() => { 24 | clearTimeout(timeout); 25 | }); 26 | }); 27 | 28 | return ( 29 |
    35 | {props.message} 36 |
    37 | ); 38 | }; 39 | 40 | export default Toast; 41 | -------------------------------------------------------------------------------- /static/src/components/upload/asyncPool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Asynchronously processes an array of items with a concurrency limit. 3 | * 4 | * @template T - Type of the input items. 5 | * @template U - Type of the result of the asynchronous task. 6 | * 7 | * @param {number} concurrencyLimit - The maximum number of asynchronous tasks to execute concurrently. 8 | * @param {T[]} items - The array of items to process asynchronously. 9 | * @param {(item: T) => Promise} asyncTask - The asynchronous task to be performed on each item. 10 | * 11 | * @returns {Promise} - A promise that resolves to an array of results from the asynchronous tasks. 12 | */ 13 | export default async function asyncPool( 14 | concurrencyLimit: number, 15 | items: T[], 16 | asyncTask: (item: T) => Promise 17 | ): Promise { 18 | const tasks: Promise[] = []; 19 | const pendings: Promise[] = []; 20 | 21 | for (const item of items) { 22 | const task = asyncTask(item); 23 | tasks.push(task); 24 | 25 | if (concurrencyLimit <= items.length) { 26 | task.then(() => { 27 | pendings.splice(pendings.indexOf(task), 1); 28 | }); 29 | pendings.push(task); 30 | 31 | if (pendings.length >= concurrencyLimit) { 32 | await Promise.race(pendings); 33 | } 34 | } 35 | } 36 | 37 | return Promise.all(tasks); 38 | } 39 | -------------------------------------------------------------------------------- /static/src/components/upload/fileItem.tsx: -------------------------------------------------------------------------------- 1 | import { BsStopCircle } from "solid-icons/bs"; 2 | import { AiFillCheckCircle } from "solid-icons/ai"; 3 | import fileType from "~/pages/receive/fileType"; 4 | import formatFileSize from "./fileSize"; 5 | import Space from "~/components/space"; 6 | import DotLoading from "~/components/loading/dot"; 7 | import List from "~/components/list"; 8 | import Progress from "../progress"; 9 | import { Show, useContext } from "solid-js"; 10 | import LocaleContext from "~/context"; 11 | import ZH_CN from "~/i18n/zh_cn"; 12 | 13 | const getExtension = (name: string): string => { 14 | const dotIndex = name.lastIndexOf("."); 15 | 16 | if (dotIndex === -1) return "UNKOWN"; 17 | 18 | return name.slice(dotIndex + 1).toUpperCase(); 19 | }; 20 | 21 | interface FileItemProps { 22 | index: number; 23 | file: File; 24 | speed?: number; 25 | percent?: number; 26 | abort?: () => void; 27 | } 28 | 29 | const FileItem = (props: FileItemProps) => { 30 | const locale = useContext(LocaleContext)!; 31 | const extension = getExtension(props.file.name); 32 | 33 | return ( 34 | 37 | {props.index + 1}. 38 | {props.file.name} 39 | 40 | } 41 | description={ 42 | 43 | 44 | {locale.file_item_file_size_label}: 45 | {formatFileSize(props.file.size)} 46 | 47 | 48 | 49 | {locale.file_item_file_type_label}:{fileType(extension)} 50 | 51 | 52 | 53 | } 54 | extra={ 55 | props.speed ? ( 56 | 63 | 64 | 65 | 66 | {props.speed.toFixed(1)} MB/s 67 | 68 | ) : props.percent === undefined ? ( 69 | 70 | 71 | 72 | ) : ( 73 | 74 | 75 | 76 | ) 77 | } 78 | foot={} 79 | /> 80 | ); 81 | }; 82 | 83 | export default FileItem; 84 | -------------------------------------------------------------------------------- /static/src/components/upload/fileSize.ts: -------------------------------------------------------------------------------- 1 | const formatFileSize = (size: number): string => { 2 | const KB = 1024; 3 | const MB = KB * 1024; 4 | const GB = MB * 1024; 5 | 6 | if (size < KB) { 7 | return `${size} B`; 8 | } else if (size < MB) { 9 | const kb = size / KB; 10 | return `${kb.toFixed(2)} KB`; 11 | } else if (size < GB) { 12 | const mb = size / MB; 13 | return `${mb.toFixed(2)} MB`; 14 | } else { 15 | const gb = size / GB; 16 | return `${gb.toFixed(2)} GB`; 17 | } 18 | }; 19 | 20 | export default formatFileSize; 21 | -------------------------------------------------------------------------------- /static/src/components/upload/index.scss: -------------------------------------------------------------------------------- 1 | #upload { 2 | flex: 18; 3 | flex-direction: column; 4 | padding: 0 10px 10px 10px; 5 | width: calc(100vw - 20px); 6 | display: flex; 7 | overflow-y: auto; 8 | 9 | .upload-file-list { 10 | // height: 100%; 11 | border-radius: 5px; 12 | flex-grow: 14; 13 | overflow-y: auto; 14 | 15 | .waiting, 16 | .done { 17 | margin-right: 10px; 18 | font-size: var(--font-size-8); 19 | } 20 | 21 | .done { 22 | color: var(--color-success); 23 | } 24 | 25 | .uploading { 26 | margin-right: 5px; 27 | width: 64px; 28 | 29 | .abort { 30 | color: var(--color-danger); 31 | font-size: var(--font-size-10); 32 | } 33 | } 34 | } 35 | 36 | .submit-button { 37 | position: fixed; 38 | left: 20px; 39 | top: 10px; 40 | border-radius: 50%; 41 | padding: 8px; 42 | } 43 | 44 | .empty { 45 | height: 100%; 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | } 50 | } 51 | 52 | .success { 53 | color: #fff; 54 | } 55 | -------------------------------------------------------------------------------- /static/src/components/upload/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type JSX, 3 | createEffect, 4 | createSignal, 5 | createUniqueId, 6 | useContext, 7 | } from "solid-js"; 8 | import { createStore } from "solid-js/store"; 9 | import { getFileItemIndex } from "./util"; 10 | import request from "./request"; 11 | import asyncPool from "~/components/upload/asyncPool"; 12 | import ErrorBlock from "../error-block"; 13 | import List from "../list"; 14 | import FileItem from "./fileItem"; 15 | import "./index.scss"; 16 | import Button from "../button"; 17 | import { AiOutlinePlus } from "solid-icons/ai"; 18 | import LocaleContext from "~/context"; 19 | 20 | interface UploadProps { 21 | action: string; 22 | headers?: Record; 23 | withCredentials?: boolean; 24 | method?: UploadRequestOption["method"]; 25 | } 26 | 27 | const Upload = ({ action, headers, withCredentials, method }: UploadProps) => { 28 | const locale = useContext(LocaleContext)!; 29 | 30 | let fileInput: HTMLInputElement | undefined; 31 | 32 | const [fileItems, setFileItems] = createStore([]); 33 | 34 | const [requestTasks, setRequestTasks] = createSignal([]); 35 | 36 | const appendTask = (task: RequestTask) => 37 | setRequestTasks((pre) => [...pre, task]); 38 | 39 | const onProgress = (e: { percent?: number; speed?: number }, file: File) => { 40 | const idx = getFileItemIndex(file, fileItems); 41 | if (idx === -1) { 42 | return; 43 | } 44 | 45 | setFileItems(idx, () => ({ percent: e.percent, speed: e.speed })); 46 | }; 47 | 48 | const onSuccess = (fileItem: FileListItem) => { 49 | const idx = getFileItemIndex(fileItem.file, fileItems); 50 | if (idx === -1) { 51 | return; 52 | } 53 | 54 | setFileItems(idx, () => ({ percent: 100, speed: undefined })); 55 | }; 56 | 57 | const onChange: JSX.ChangeEventHandlerUnion = ( 58 | e, 59 | ) => { 60 | const { files } = e.target; 61 | 62 | if (!files) return; 63 | 64 | const fileItems: FileListItem[] = []; 65 | for (const file of files) { 66 | fileItems.push({ file, id: createUniqueId() }); 67 | } 68 | 69 | for (const f of fileItems) { 70 | send(f); 71 | } 72 | 73 | setFileItems(fileItems); 74 | }; 75 | 76 | const onClick: JSX.EventHandlerUnion = (e) => { 77 | const target = e.target as HTMLElement; 78 | 79 | if ( 80 | target && 81 | (target.tagName === "BUTTON" || 82 | target.parentElement?.tagName === "BUTTON" || 83 | target.parentElement?.parentElement?.tagName === "BUTTON") 84 | ) { 85 | setFileItems([]); 86 | setRequestTasks([]); 87 | // biome-ignore lint/style/noNonNullAssertion: 88 | fileInput!.value = ""; // 点击按钮后清空 input files 89 | fileInput?.click(); 90 | target.blur(); 91 | } 92 | }; 93 | 94 | const send = (fileItem: FileListItem) => { 95 | const option: UploadRequestOption = { 96 | id: fileItem.id, 97 | action, 98 | file: fileItem.file, 99 | method: method || "POST", 100 | headers, 101 | withCredentials, 102 | onProgress, 103 | onError: (e) => { 104 | console.log(e); 105 | // todo: 反馈上传错误 106 | }, 107 | onSuccess: () => { 108 | onSuccess(fileItem); 109 | }, 110 | }; 111 | 112 | request(option, appendTask); 113 | }; 114 | 115 | createEffect(() => { 116 | // 任务数量与文件数量相等后才发送请求 117 | if (!fileItems.length || fileItems.length !== requestTasks().length) return; 118 | 119 | asyncPool( 120 | 2, // 并发数量,应该根据实际场景判断,比如通过 fileList 中的文件尺寸进行判断,当超过某个阈值时减小并发量 121 | requestTasks(), 122 | (item) => 123 | new Promise((resolve) => { 124 | const xhr = item.xhr; 125 | 126 | item.done = resolve; 127 | item.start = new Date().getTime() / 1000; 128 | 129 | xhr.send(item.data); 130 | }), 131 | ); 132 | }); 133 | 134 | return ( 135 |
    136 |
    137 | {!fileItems.length ? ( 138 |
    139 | 144 |
    145 | ) : ( 146 | ( 150 | { 156 | // 中断请求 157 | requestTasks() 158 | .find((t) => t.id === item.id) 159 | ?.xhr.abort(); 160 | // 删除文件 161 | setFileItems((pre) => pre.filter((i) => i.id !== item.id)); 162 | }} 163 | /> 164 | )} 165 | /> 166 | )} 167 |
    168 | 175 | 176 | 182 |
    183 | ); 184 | }; 185 | 186 | export default Upload; 187 | -------------------------------------------------------------------------------- /static/src/components/upload/request.ts: -------------------------------------------------------------------------------- 1 | function getError(option: UploadRequestOption, xhr: XMLHttpRequest) { 2 | const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`; 3 | const err = new Error(msg) as UploadRequestError; 4 | err.status = xhr.status; 5 | err.method = option.method; 6 | err.url = option.action; 7 | return err; 8 | } 9 | 10 | function getBody(xhr: XMLHttpRequest) { 11 | const text = xhr.responseText || xhr.response; 12 | if (!text) { 13 | return text; 14 | } 15 | 16 | try { 17 | return JSON.parse(text); 18 | } catch (e) { 19 | return text; 20 | } 21 | } 22 | 23 | const upload = ( 24 | option: UploadRequestOption, 25 | appendTask: (task: RequestTask) => void, 26 | ) => { 27 | const xhr = new XMLHttpRequest(); 28 | 29 | const task: RequestTask = { id: option.id, xhr, data: option.file }; 30 | 31 | if (option.onProgress && xhr.upload) { 32 | xhr.upload.onprogress = function progress(e: UploadProgressEvent) { 33 | if (e.total! > 0) { 34 | e.percent = (e.loaded! / e.total!) * 100; 35 | 36 | const now = new Date().getTime() / 1000; 37 | const delta = now - task.start!; 38 | 39 | if (delta) e.speed = e.loaded! / 1024 / 1024 / delta; 40 | else e.speed = 0; 41 | } 42 | option.onProgress(e, option.file); 43 | }; 44 | } 45 | 46 | xhr.onabort = () => { 47 | task.done!(); 48 | }; 49 | 50 | xhr.onerror = function error(e) { 51 | option.onError(e); 52 | task.done!(); 53 | }; 54 | 55 | xhr.onload = function onload() { 56 | // 代码执行到这里时 done 一定存在 57 | task.done!(); 58 | 59 | // allow success when 2xx status 60 | // see https://github.com/react-component/upload/issues/34 61 | if (xhr.status < 200 || xhr.status >= 300) { 62 | return option.onError!(getError(option, xhr), getBody(xhr)); 63 | } 64 | 65 | return option.onSuccess(getBody(xhr), xhr); 66 | }; 67 | 68 | xhr.open(option.method, option.action + "?name=" + option.file.name, true); 69 | 70 | // Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179 71 | if (option.withCredentials && "withCredentials" in xhr) { 72 | xhr.withCredentials = true; 73 | } 74 | 75 | const headers = option.headers || {}; 76 | 77 | // when set headers['X-Requested-With'] = null , can close default XHR header 78 | // see https://github.com/react-component/upload/issues/33 79 | if (headers["X-Requested-With"] !== null) { 80 | xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); 81 | } 82 | 83 | Object.keys(headers).forEach((h) => { 84 | if (headers[h] !== null) { 85 | xhr.setRequestHeader(h, headers[h]); 86 | } 87 | }); 88 | 89 | appendTask(task); 90 | }; 91 | 92 | export default upload; 93 | -------------------------------------------------------------------------------- /static/src/components/upload/util.ts: -------------------------------------------------------------------------------- 1 | export const getFileItemIndex = ( 2 | file: File, 3 | fileList: FileListItem[], 4 | ): number => { 5 | return fileList.findIndex( 6 | (item) => item.file.size === file.size && item.file.name === file.name, 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /static/src/components/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const addClassNames = ( 2 | base: string, 3 | ...others: (string | undefined | false)[] 4 | ): string => { 5 | const names = Array.from(new Set(others.filter((s) => s && s !== ""))); 6 | return [base, ...names].join(" "); 7 | }; 8 | -------------------------------------------------------------------------------- /static/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "solid-js"; 2 | import type { Locale } from "./i18n"; 3 | 4 | const LocaleContext = createContext(); 5 | 6 | export default LocaleContext; 7 | -------------------------------------------------------------------------------- /static/src/i18n/en_us.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "."; 2 | 3 | const EN_US: Locale = { 4 | invalid_request: "Invalid request", 5 | mode_is_required: "Query parameter 'mode' is required", 6 | 7 | file_item_file_size_label: "Size", 8 | file_item_file_type_label: "Type", 9 | 10 | send_page_title: "Send File", 11 | send_page_empty_title: "No File Selected", 12 | send_page_empty_description: 13 | "Click the plus button in the top left corner to select files", 14 | send_page_uploading_tooltip: 15 | "Click the red button on the right to interrupt unfinished tasks", 16 | 17 | receive_page_title: "Receive File", 18 | receive_page_toast: 19 | "Do not refresh this page, or the file list will be cleared", 20 | receive_page_file_list_header: 21 | "Click the filename or the button on the right to download", 22 | }; 23 | 24 | export default EN_US; 25 | -------------------------------------------------------------------------------- /static/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import EN_US from "./en_us"; 2 | import ZH_CN from "./zh_cn"; 3 | 4 | export interface Locale { 5 | invalid_request: string; 6 | mode_is_required: string; 7 | 8 | file_item_file_size_label: string; 9 | file_item_file_type_label: string; 10 | 11 | send_page_title: string; 12 | send_page_empty_title: string; 13 | send_page_empty_description: string; 14 | send_page_uploading_tooltip: string; 15 | 16 | receive_page_title: string; 17 | receive_page_toast: string; 18 | receive_page_file_list_header: string; 19 | } 20 | 21 | export const getLocale = (): Locale => { 22 | const language = navigator.language; 23 | 24 | if (language === "zh-CN") { 25 | return ZH_CN; 26 | } 27 | 28 | return EN_US; 29 | }; 30 | -------------------------------------------------------------------------------- /static/src/i18n/zh_cn.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "."; 2 | 3 | const ZH_CN: Locale = { 4 | invalid_request: "无效的请求", 5 | mode_is_required: "缺少查询参数:mode", 6 | 7 | file_item_file_size_label: "大小", 8 | file_item_file_type_label: "类型", 9 | 10 | send_page_title: "发送文件", 11 | send_page_empty_title: "未选择文件", 12 | send_page_empty_description: "点击左上角的加号按钮选择文件", 13 | send_page_uploading_tooltip: "未完成的任务可点击右侧红色按钮中断", 14 | 15 | receive_page_title: "接收文件", 16 | receive_page_toast: "不要刷新此页面,否则文件列表将会被清空", 17 | receive_page_file_list_header: "点击文件名或右侧按钮即可下载", 18 | }; 19 | 20 | export default ZH_CN; 21 | -------------------------------------------------------------------------------- /static/src/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-text: #333; 3 | --color-background: #fff; 4 | --color-shadow: #edeef1; 5 | 6 | --color-weak: #999; 7 | --color-light: #ccc; 8 | --color-border: #eee; 9 | 10 | --color-primary: #646cff; 11 | --color-success: #34b368; 12 | --color-warning: #ff8f1f; 13 | --color-error: #ff3141; 14 | --color-danger: var(--color-error); 15 | 16 | --color-status-success: var(--color-success); 17 | --color-status-waiting: var(--color-primary); 18 | --color-status-info: var(--color-primary); 19 | --color-status-warning: #000; 20 | --color-status-error: #000; 21 | 22 | --color-button-text: #fff; 23 | --color-button-disabled-background: #bdc0ff; 24 | --color-button-disabled-text: #eee; 25 | 26 | --color-progress-text: var(--color-button-text); 27 | 28 | --color-scrollbar: rgba(0, 0, 0, 0.1); 29 | --color-scrollbar-hover: rgba(0, 0, 0, 0.4); 30 | 31 | --font-size-1: 9px; 32 | --font-size-2: 10px; 33 | --font-size-3: 11px; 34 | --font-size-4: 12px; 35 | --font-size-5: 13px; 36 | --font-size-6: 14px; 37 | --font-size-7: 15px; 38 | --font-size-8: 16px; 39 | --font-size-9: 17px; 40 | --font-size-10: 18px; 41 | 42 | --font-size-main: var(--adm-font-size-5); 43 | 44 | --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 45 | "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", 46 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 47 | 48 | --motion-duration-slow: 0.3s; 49 | --motion-ease-in-out-circ: cubic-bezier(0.78, 0.14, 0.15, 0.86); 50 | 51 | --border-radius: 5px; 52 | 53 | --z-index-level-0: 0; 54 | --z-index-level-1: 4; 55 | --z-index-level-2: 8; 56 | --z-index-level-3: 12; 57 | --z-index-level-4: 16; 58 | --z-index-level-5: 20; 59 | --z-index-level-6: 24; 60 | --z-index-level-7: 28; 61 | --z-index-level-8: 32; 62 | --z-index-level-9: 36; 63 | --z-index-level-top: var(--z-index-level-9); 64 | --z-index-level-bottom: var(--z-index-level-0); 65 | 66 | font-family: var(--font-family); 67 | 68 | color-scheme: light dark; 69 | 70 | color: var(--color-text); 71 | background-color: var(--color-background); 72 | } 73 | 74 | * { 75 | padding: 0; 76 | margin: 0; 77 | } 78 | 79 | ::-webkit-scrollbar { 80 | width: 8px; 81 | height: 8px; 82 | } 83 | 84 | ::-webkit-scrollbar-thumb { 85 | border-radius: 10px; 86 | background: var(--color-scrollbar); 87 | } 88 | 89 | ::-webkit-scrollbar-thumb:hover { 90 | background: var(--color-scrollbar-hover); 91 | } 92 | 93 | ::-webkit-scrollbar-track { 94 | background-color: #fff0; 95 | } 96 | 97 | .dark { 98 | --color-text: var(--color-light); 99 | --color-background: #2f2f2f; 100 | --color-shadow: #000; 101 | --color-button-text: var(--color-light); 102 | --color-button-disabled-background: #2c307e; 103 | --color-button-disabled-text: #3e3e3e; 104 | --color-progress-text: var(--color-button-text); 105 | 106 | --color-scrollbar: rgba(255, 255, 255, 0.1); 107 | --color-scrollbar-hover: rgba(255, 255, 255, 0.2); 108 | } 109 | -------------------------------------------------------------------------------- /static/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | 4 | import "./index.scss"; 5 | import App from "./App"; 6 | 7 | const root = document.getElementById("root"); 8 | 9 | render(() => , root!); 10 | -------------------------------------------------------------------------------- /static/src/lazy.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/static/src/lazy.ts -------------------------------------------------------------------------------- /static/src/pages/receive/fileType.ts: -------------------------------------------------------------------------------- 1 | interface FileType { 2 | extensions: string[]; 3 | name: string; 4 | } 5 | 6 | const FILE_TYPES: FileType[] = [ 7 | { 8 | extensions: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "BMP", "SVG", "ICNS"], 9 | name: "图片", 10 | }, 11 | { 12 | extensions: ["MKV", "MP4", "AVI", "MOV"], 13 | name: "视频", 14 | }, 15 | { 16 | extensions: ["MP3", "FLAC"], 17 | name: "音频", 18 | }, 19 | { 20 | extensions: [ 21 | "DOC", 22 | "DOCX", 23 | "PPT", 24 | "PPTX", 25 | "XLS", 26 | "XLSX", 27 | "PAGES", 28 | "NUMBERS", 29 | "KEYNOTES", 30 | "MD", 31 | "PDF", 32 | ], 33 | name: "文档", 34 | }, 35 | { 36 | extensions: ["TXT", "YAML", "YML", "TOML", "INI", "JSON", "HTML"], 37 | name: "文本", 38 | }, 39 | { 40 | extensions: ["ZIP", "RAR", "7Z", "TAR"], 41 | name: "压缩文件", 42 | }, 43 | { 44 | extensions: [ 45 | "PY", 46 | "RUST", 47 | "TS", 48 | "TSX", 49 | "JS", 50 | "JSX", 51 | "SCSS", 52 | "CSS", 53 | "LESS", 54 | "JAVA", 55 | "LUA", 56 | "CJS", 57 | "CPP", 58 | "C", 59 | "GO", 60 | ], 61 | name: "代码文件", 62 | }, 63 | { 64 | extensions: ["APK", "EXE", "MSI", "DMG", "IPA", "DEB", "RPM", "APPIMAGE"], 65 | name: "应用程序", 66 | }, 67 | ]; 68 | 69 | const fileType = (extension: string) => { 70 | const ft = FILE_TYPES.find((ft) => ft.extensions.includes(extension)); 71 | 72 | return ft ? ft.name : "未知"; 73 | }; 74 | 75 | export default fileType; 76 | -------------------------------------------------------------------------------- /static/src/pages/receive/index.scss: -------------------------------------------------------------------------------- 1 | #receive { 2 | .content { 3 | overflow-y: auto; 4 | } 5 | 6 | .receive-file-list { 7 | .download-url { 8 | white-space: normal; 9 | word-break: break-all; 10 | } 11 | 12 | .file-description { 13 | color: var(--color-weak); 14 | margin-top: 4px; 15 | } 16 | 17 | .download-icon { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | font-size: 24px; 22 | padding: 0 10px; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /static/src/pages/receive/index.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Show, Switch, createResource, useContext } from "solid-js"; 2 | import { AiOutlineCloudDownload } from "solid-icons/ai"; 3 | import Result from "~/components/result"; 4 | import SpinLoading from "~/components/loading/spin"; 5 | import Space from "~/components/space"; 6 | import Toast from "~/components/toast"; 7 | import List from "~/components/list"; 8 | import fileType from "./fileType"; 9 | import Link from "~/components/link"; 10 | import "./index.scss"; 11 | import LocaleContext from "~/context"; 12 | import ZH_CN from "~/i18n/zh_cn"; 13 | 14 | type ResponseData = SendFile[] | BadRequest; 15 | 16 | const fetchData = async (): Promise => { 17 | const response = await fetch("/files"); 18 | 19 | if (response.status !== 200) { 20 | const msg = await response.json(); 21 | return msg; 22 | } 23 | 24 | const body: SendFile[] = await response.json(); 25 | return body; 26 | }; 27 | 28 | const Receive = () => { 29 | const locale = useContext(LocaleContext)!; 30 | 31 | const [data] = createResource(fetchData); 32 | 33 | return ( 34 |
    35 |
    {locale.receive_page_title}
    36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
    47 | 53 |
    54 |
    55 | 56 | 57 |
    58 | 59 | 60 | { 65 | const url = "/download/" + encodeURIComponent(item.path); 66 | return ( 67 | 70 | {index() + 1}. 71 | 76 | {item.name} 77 | 78 | 79 | } 80 | description={ 81 | 82 | 83 | {locale.file_item_file_size_label}:{item.size} 84 | 85 | 86 | 类型:{fileType(item.extension)} 87 | 88 | 89 | } 90 | extra={[ 91 | 96 | 97 | , 98 | ]} 99 | /> 100 | ); 101 | }} 102 | /> 103 |
    104 |
    105 |
    106 |
    107 | ); 108 | }; 109 | 110 | export default Receive; 111 | -------------------------------------------------------------------------------- /static/src/pages/send/index.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alley-rs/fluxy/c6a376a5841e14b755c4b36027d9238895508c91/static/src/pages/send/index.scss -------------------------------------------------------------------------------- /static/src/pages/send/index.tsx: -------------------------------------------------------------------------------- 1 | import Upload from "~/components/upload"; 2 | import "./index.scss"; 3 | import { useContext } from "solid-js"; 4 | import LocaleContext from "~/context"; 5 | 6 | const Send = () => { 7 | const locale = useContext(LocaleContext)!; 8 | 9 | return ( 10 |
    11 |
    {locale.send_page_title}
    12 | 13 |
    14 | ); 15 | }; 16 | 17 | export default Send; 18 | -------------------------------------------------------------------------------- /static/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /static/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | "jsxImportSource": "solid-js", 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "paths": { 26 | "~/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "src", 33 | "types/**/*.d.ts", 34 | "../types/**/*.d.ts" 35 | ], 36 | "references": [ 37 | { 38 | "path": "./tsconfig.node.json" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /static/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /static/types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface RequestTask { 2 | id: string; 3 | xhr: XMLHttpRequest; 4 | data: File; 5 | start?: number; 6 | done?: () => void; 7 | } 8 | 9 | interface BadRequest { 10 | error: string; 11 | advice: string | null; 12 | } 13 | -------------------------------------------------------------------------------- /static/types/jsx.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSX } from "solid-js"; 2 | 3 | declare module "solid-js" { 4 | namespace JSX { 5 | interface SvgSVGAttributes 6 | extends ContainerElementSVGAttributes, 7 | NewViewportSVGAttributes, 8 | ConditionalProcessingSVGAttributes, 9 | ExternalResourceSVGAttributes, 10 | StylableSVGAttributes, 11 | FitToViewBoxSVGAttributes, 12 | ZoomAndPanSVGAttributes, 13 | PresentationSVGAttributes { 14 | version?: string; 15 | baseProfile?: string; 16 | x?: number | string; 17 | y?: number | string; 18 | width?: number | string; 19 | height?: number | string; 20 | contentScriptType?: string; 21 | contentStyleType?: string; 22 | xmlns?: string; 23 | "xmlns:xlink"?: string; 24 | } 25 | 26 | interface UseSVGAttributes 27 | extends GraphicsElementSVGAttributes, 28 | ConditionalProcessingSVGAttributes, 29 | ExternalResourceSVGAttributes, 30 | StylableSVGAttributes, 31 | TransformableSVGAttributes { 32 | x?: number | string; 33 | y?: number | string; 34 | width?: number | string; 35 | height?: number | string; 36 | href?: string; 37 | fill?: string; 38 | "xlink:href"?: string; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/types/upload.d.ts: -------------------------------------------------------------------------------- 1 | interface FileListItem { 2 | id: string; 3 | file: File; 4 | speed?: number; 5 | percent?: number; 6 | } 7 | 8 | interface UploadRequestError extends Error, ProgressEvent { 9 | status?: number; 10 | method?: UploadRequestOption["method"]; 11 | url?: string; 12 | name?: string; 13 | message?: string; 14 | } 15 | 16 | interface UploadProgressEvent extends ProgressEvent { 17 | percent?: number; 18 | speed?: number; 19 | } 20 | 21 | interface UploadRequestOption extends FileListItem { 22 | action: string; 23 | method: "POST" | "PUT"; 24 | onProgress: (e: UploadProgressEvent, file: File) => void; 25 | onError: (e: UploadRequestError, body?: object | string) => void; 26 | onSuccess: (body: object | string, xhr: XMLHttpRequest) => void; 27 | withCredentials?: boolean; 28 | headers?: Record; 29 | } 30 | -------------------------------------------------------------------------------- /static/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solid from "vite-plugin-solid"; 3 | import path from "node:path"; 4 | 5 | const pathSrc = path.resolve(__dirname, "src"); 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | server: { 10 | host: "0.0.0.0", 11 | proxy: { 12 | "/upload": "http://127.0.0.1:5800", 13 | "/ping": "http://127.0.0.1:5800", 14 | "/files": "http://127.0.0.1:5800", 15 | "/download": "http://127.0.0.1:5800", 16 | }, 17 | }, 18 | 19 | resolve: { 20 | alias: { 21 | "~/": `${pathSrc}/`, 22 | }, 23 | }, 24 | 25 | build: { 26 | outDir: "../src-tauri/static", 27 | }, 28 | 29 | plugins: [solid()], 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "preserve", 15 | "jsxImportSource": "solid-js", 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "paths": { 22 | "~/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["src", "types/**/*.d.ts"], 26 | "references": [ 27 | { 28 | "path": "./tsconfig.node.json" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /types/context.d.ts: -------------------------------------------------------------------------------- 1 | type Accessor = import("solid-js").Accessor; 2 | type Resource = import("solid-js").Resource; 3 | type Setter = import("solid-js").Setter; 4 | 5 | interface AppContext { 6 | goHome: () => void; 7 | about: { 8 | show: Accessor; 9 | onShow: () => void; 10 | }; 11 | translations: Resource; 12 | } 13 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface TaskMessage { 2 | path: string; 3 | name: string; 4 | percent: number; 5 | speed: number; 6 | size: string; 7 | aborted: boolean; 8 | } 9 | 10 | interface QrCode { 11 | svg: string; 12 | url: string; 13 | id: number; 14 | } 15 | 16 | interface SendFile { 17 | name: string; 18 | path: string; 19 | extension: string; 20 | size: string; 21 | } 22 | 23 | type CSSProperties = JSX.CSSProperties; 24 | 25 | interface Translations { 26 | window_title: string; 27 | dark_mode_tooltip: string; 28 | light_mode_tooltip: string; 29 | about_button_tooltip: string; 30 | about_dialog_github_tooltip: string; 31 | about_dialog_feedback_tooltip: string; 32 | home_label_text: string; 33 | home_button_text: string; 34 | home_send_button_text: string; 35 | home_receive_button_text: string; 36 | qrcode_page_title: string; 37 | qrcode_page_url_label: string; 38 | qrcode_page_url_tooltip: string; 39 | qrcode_page_url_copied_message: string; 40 | qrcode_page_toast_message: string; 41 | ok_button_text: string; 42 | clear_button_text: string; 43 | send_page_title: string; 44 | send_page_empty_drop_description: string; 45 | send_page_drop_description: string; 46 | list_item_file_size_label: string; 47 | list_item_file_type_label: string; 48 | send_page_list_item_tooltip: string; 49 | receive_page_empty_description: string; 50 | receive_page_list_item_tooltip: string; 51 | receive_page_dropdown_open_button_label: string; 52 | receive_page_dropdown_pick_button_label: string; 53 | receive_page_directory_path_label: string; 54 | receive_page_directory_path_tooltip: string; 55 | } 56 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solid from "vite-plugin-solid"; 3 | import path from "node:path"; 4 | 5 | const pathSrc = path.resolve(__dirname, "src"); 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(async () => ({ 9 | resolve: { 10 | alias: { 11 | "~/": `${pathSrc}/`, 12 | }, 13 | conditions: ["development", "browser"], 14 | }, 15 | 16 | plugins: [solid()], 17 | 18 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 19 | // 20 | // 1. prevent vite from obscuring rust errors 21 | clearScreen: false, 22 | // 2. tauri expects a fixed port, fail if that port is not available 23 | server: { 24 | port: 1420, 25 | strictPort: true, 26 | watch: { 27 | // 3. tell vite to ignore watching `src-tauri` 28 | ignored: ["**/src-tauri/**"], 29 | }, 30 | }, 31 | })); 32 | --------------------------------------------------------------------------------