├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── README.md ├── components.json ├── index.html ├── notes.md ├── package-lock.json ├── package.json ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── 64x64.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── android │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ └── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── ios │ │ ├── AppIcon-20x20@1x.png │ │ ├── AppIcon-20x20@2x-1.png │ │ ├── AppIcon-20x20@2x.png │ │ ├── AppIcon-20x20@3x.png │ │ ├── AppIcon-29x29@1x.png │ │ ├── AppIcon-29x29@2x-1.png │ │ ├── AppIcon-29x29@2x.png │ │ ├── AppIcon-29x29@3x.png │ │ ├── AppIcon-40x40@1x.png │ │ ├── AppIcon-40x40@2x-1.png │ │ ├── AppIcon-40x40@2x.png │ │ ├── AppIcon-40x40@3x.png │ │ ├── AppIcon-512@2x.png │ │ ├── AppIcon-60x60@2x.png │ │ ├── AppIcon-60x60@3x.png │ │ ├── AppIcon-76x76@1x.png │ │ ├── AppIcon-76x76@2x.png │ │ └── AppIcon-83.5x83.5@2x.png ├── src │ ├── download │ │ └── mod.rs │ ├── events.rs │ ├── file_operations │ │ └── mod.rs │ ├── files.rs │ ├── iroh.rs │ ├── lib.rs │ ├── main.rs │ ├── state │ │ ├── mod.rs │ │ └── user_data.rs │ ├── theme │ │ └── mod.rs │ ├── ticket │ │ └── mod.rs │ └── utils.rs └── tauri.conf.json ├── src ├── assets │ ├── avatars │ │ ├── avatar1.png │ │ ├── avatar10.png │ │ ├── avatar11.png │ │ ├── avatar12.png │ │ ├── avatar2.png │ │ ├── avatar3.png │ │ ├── avatar4.png │ │ ├── avatar5.png │ │ ├── avatar6.png │ │ ├── avatar7.png │ │ ├── avatar8.png │ │ ├── avatar9.png │ │ └── index.ts │ └── logo.png ├── components │ ├── animated-checkmark.tsx │ ├── loader.tsx │ ├── titlebar.tsx │ ├── toggle-theme.tsx │ └── ui │ │ ├── button.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── scroll-area.tsx │ │ ├── sonner.tsx │ │ └── tooltip.tsx ├── context │ ├── edit-profile.tsx │ └── theme.context.tsx ├── index.css ├── lib │ ├── post-hog.ts │ ├── tanstack-router.ts │ ├── tauri │ │ ├── api.ts │ │ ├── events.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ └── zustand.ts ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── _pages.tsx │ └── _pages │ │ ├── -components │ │ ├── progress-bar.tsx │ │ ├── queue-container.tsx │ │ └── queue-item.tsx │ │ ├── edit-profile.tsx │ │ ├── index.tsx │ │ ├── onboard.tsx │ │ ├── receive.tsx │ │ └── send.tsx ├── state │ └── appstate.tsx ├── utils │ ├── async.ts │ ├── fs.ts │ ├── index.ts │ ├── style.ts │ ├── type-utils.ts │ └── webview.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | publish-tauri: 8 | permissions: 9 | contents: write 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | include: 14 | - platform: 'macos-latest' # for Arm based macs 15 | args: '--target aarch64-apple-darwin' 16 | 17 | - platform: 'macos-latest' # for Intel based macs 18 | args: '--target x86_64-apple-darwin' 19 | 20 | - platform: 'ubuntu-22.04' 21 | args: '' 22 | 23 | - platform: 'windows-latest' 24 | args: '' 25 | 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: install dependencies (ubuntu only) 31 | if: matrix.platform == 'ubuntu-22.04' 32 | run: | 33 | sudo apt-get update 34 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 35 | 36 | - name: setup node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: lts/* 40 | cache: '' 41 | 42 | - name: install rust stable 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | # Since these targets are only used on macos runners so they can be omitted for windows and ubuntu for faster builds 46 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 47 | 48 | - name: Rust cache 49 | uses: swatinem/rust-cache@v2 50 | with: 51 | workspaces: './src-tauri -> target' 52 | 53 | - name: install frontend dependencies 54 | run: npm install 55 | 56 | - uses: tauri-apps/tauri-action@v0 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} 60 | VITE_POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} 61 | with: 62 | tagName: ${{ github.ref_name }} # This action automatically replaces \_\_VERSION\_\_ with the release version 63 | releaseName: SendIt v__VERSION__ 64 | releaseBody: 'See the assets to download this version and install.' 65 | releaseDraft: true 66 | prerelease: false 67 | args: ${{ matrix.args }} 68 | -------------------------------------------------------------------------------- /.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 | .env 10 | bun.lock 11 | 12 | node_modules 13 | dist 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "plugins": ["prettier-plugin-tailwindcss"], 4 | "singleQuote": true, 5 | "jsxSingleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SendIt 2 | 3 | A modern, secure peer-to-peer file sharing application built with Tauri and React. 4 | 5 | ## Features 6 | 7 | - Simple and intuitive user interface 8 | - Fast peer-to-peer file transfers 9 | - Cross-platform support (Windows, macOS, Linux) 10 | - No file size limits 11 | - No intermediary servers - direct device-to-device transfer 12 | 13 | ## Tech Stack 14 | 15 | - **Frontend**: React 16 | - **Backend**: Rust 17 | - **Framework**: Tauri 18 | - **Build Tool**: Vite 19 | 20 | ## Prerequisites 21 | 22 | - Node.js (v16 or higher) 23 | - Rust (latest stable) 24 | - System dependencies for Tauri (see [Tauri prerequisites](https://tauri.app/start/prerequisites)) 25 | 26 | ## Development Setup 27 | 28 | 1. Clone the repository: 29 | ```bash 30 | git clone https://github.com/frstycodes/sendit.git 31 | cd sendit 32 | ``` 33 | 34 | 2. Install dependencies: 35 | ```bash 36 | npm install 37 | ``` 38 | 39 | 2. Run the development server: 40 | ```bash 41 | npm run tauri dev 42 | ``` 43 | 44 | ## Building 45 | 46 | To create a production build: 47 | 48 | ```bash 49 | npm run tauri build 50 | ``` 51 | 52 | The built applications will be available in the `src-tauri/target/release` directory. 53 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "rose", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | Tauri + React + Typescript 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | ## __Fixes:__ 2 | - [ ] 1: Disable right click context menu. 3 | - [ ] 2: Disable __Window Resizing__. 4 | - [ ] 3: Change __App Icon__. 5 | - [ ] 4: Move the upload and download queue to __Global State__(Zustand preferred), so the UI doesn't lose its state when user navigates away. 6 | - [ ] 5: Fix __Download Progress__ not working. 7 | - [ ] 6: User should be able to __Cancel__ downloads. 8 | - [ ] 7: User should be able to __Cancel__ uploads. 9 | 10 | 11 | ## __Future Features:__ 12 | - [ ] 1: Implement __Drag and Drop__ functionality. 13 | - [ ] 2: Add extensive __Icon Library__ for file icons. 14 | - [ ] 3: Allow user to __Minimize__ the app the system tray. 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "send-it", 3 | "dev": "vite", 4 | "private": true, 5 | "version": "0.4.1", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "tauri": "tauri" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-dropdown-menu": "^2.1.6", 15 | "@radix-ui/react-scroll-area": "^1.2.3", 16 | "@radix-ui/react-slot": "^1.1.2", 17 | "@radix-ui/react-tooltip": "^1.1.8", 18 | "@tailwindcss/vite": "^4.1.4", 19 | "@tanstack/react-router": "^1.114.17", 20 | "@tauri-apps/api": "^2", 21 | "@tauri-apps/plugin-clipboard-manager": "^2.2.2", 22 | "@tauri-apps/plugin-dialog": "^2.2.0", 23 | "@tauri-apps/plugin-log": "^2.3.1", 24 | "@tauri-apps/plugin-opener": "^2.2.6", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "lucide-react": "^0.479.0", 28 | "motion": "^12.5.0", 29 | "neverthrow": "^8.2.0", 30 | "posthog-js": "^1.236.2", 31 | "react": "^19.1.0", 32 | "react-dom": "^19.1.0", 33 | "sonner": "^2.0.1", 34 | "tailwind-merge": "^3.0.2", 35 | "tailwindcss": "^4.1.4", 36 | "tauri-plugin-windows-version-api": "^2.0.0", 37 | "tw-animate-css": "^1.2.5", 38 | "zustand": "^5.0.3" 39 | }, 40 | "devDependencies": { 41 | "@tanstack/react-router-devtools": "^1.114.21", 42 | "@tanstack/router-plugin": "^1.114.17", 43 | "@tauri-apps/cli": "^2", 44 | "@types/react": "^19.1.0", 45 | "@types/react-dom": "^19.1.1", 46 | "@vitejs/plugin-react": "^4.3.4", 47 | "prettier": "^3.5.3", 48 | "prettier-plugin-tailwindcss": "^0.6.11", 49 | "typescript": "~5.6.2", 50 | "vite": "^6.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sendit" 3 | version = "0.4.4" 4 | description = "A Tauri App" 5 | authors = ["Sandesh Pandey"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "tauri_send_me_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [profile.release] 18 | codegen-units = 1 19 | lto = true 20 | 21 | [build-dependencies] 22 | tauri-build = { version = "2", features = [] } 23 | 24 | [dependencies] 25 | iroh-io = "0.6" 26 | serde = { version = "1", features = ["derive"] } 27 | tokio = { version = "1", features = ["full"] } 28 | tracing = "0.1.40" 29 | n0-future = "0.1.2" 30 | 31 | tauri = { version = "2", features = [] } 32 | tauri-plugin-opener = "2" 33 | serde_json = "1" 34 | tauri-plugin-dialog = "2" 35 | iroh-blobs = { version = "0.34.0", features = ["net_protocol", "rpc"] } 36 | iroh-gossip = { version = "0.34.0", features = ["rpc"] } 37 | iroh = "0.34.0" 38 | quic-rpc = "0.19.0" 39 | tauri-plugin-clipboard-manager = "2" 40 | file_icon_provider = "0.4.0" 41 | image = "0.25.6" 42 | tauri-plugin-log = "2" 43 | log = "0.4.27" 44 | window-vibrancy = "0.6.0" 45 | tauri-plugin-windows-version = "2.0.0" 46 | rand = "0.9.1" 47 | anyhow = "1.0.98" 48 | data-encoding = "2.9.0" 49 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "dialog:default", 11 | "core:window:allow-start-dragging", 12 | "core:window:allow-minimize", 13 | "core:window:allow-close", 14 | "clipboard-manager:default", 15 | "clipboard-manager:allow-write-text", 16 | "opener:default", 17 | "opener:allow-reveal-item-in-dir", 18 | "log:default", 19 | "windows-version:default" 20 | ] 21 | } -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /src-tauri/src/download/mod.rs: -------------------------------------------------------------------------------- 1 | use iroh_gossip::net::GossipReceiver; 2 | use log::{error, info, warn}; 3 | use n0_future::stream::StreamExt; 4 | use std::str::FromStr; 5 | use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Instant}; 6 | use tauri::{AppHandle, Emitter, Listener, Manager}; 7 | 8 | use crate::state::AppState; 9 | use crate::{events, files, state::State, utils}; 10 | use iroh::NodeAddr; 11 | use iroh_blobs::{ 12 | get::db::DownloadProgress, 13 | store::{ExportFormat, ExportMode}, 14 | ticket::BlobTicket, 15 | }; 16 | 17 | pub async fn subscribe_upload_progress(rx: GossipReceiver) { 18 | todo!("Need implementation details") 19 | } 20 | 21 | #[tauri::command] 22 | pub async fn download_header( 23 | state: State<'_>, 24 | ticket: String, 25 | handle: AppHandle, 26 | ) -> Result<(), String> { 27 | info!("Downloading with ticket: {}", ticket); 28 | let handle = Arc::new(handle); 29 | let export_dir = Arc::new(utils::get_download_dir(&handle)?); 30 | 31 | let iroh = match cfg!(debug_assertions) { 32 | true => &state.iroh_debug, 33 | false => state.iroh(), 34 | }; 35 | 36 | let blobs = &iroh.blobs; 37 | 38 | let ticket = 39 | BlobTicket::from_str(&ticket).map_err(|e| format!("Failed to parse ticket: {}", e))?; 40 | let remote_node_addr = ticket.node_addr().clone(); 41 | 42 | // Download and read the header file 43 | let header_content = utils::download_and_read_header(&blobs, ticket).await?; 44 | 45 | let handles = std::sync::Mutex::new(HashMap::new()); 46 | let mut files = files::Files::from_str(header_content.as_str())?; 47 | let mut tasks = Vec::with_capacity(files.len()); 48 | 49 | for (_, file) in files.drain() { 50 | let payload = events::DownloadFileAdded { 51 | name: file.name.clone(), 52 | icon: file.icon.clone(), 53 | size: file.size.clone(), 54 | }; 55 | 56 | handle.emit(events::DOWNLOAD_FILE_ADDED, payload).ok(); 57 | 58 | let export_dir = Arc::clone(&export_dir); 59 | let handle = Arc::clone(&handle); 60 | let filename = file.name.clone(); 61 | let remote_node_addr = remote_node_addr.clone(); 62 | 63 | // Spawn a new task for each file download 64 | let task = tokio::spawn(async move { 65 | let name = file.name.clone(); 66 | let res = download_file(&handle, file, &export_dir, remote_node_addr).await; 67 | if let Err(error) = res { 68 | error!("Failed to download file: {}", error); 69 | let payload = events::DownloadFileError { name, error }; 70 | handle.emit(events::DOWNLOAD_FILE_ERROR, payload).ok(); 71 | }; 72 | }); 73 | 74 | // Store the abort handler for the task in a map 75 | let handle = task.abort_handle(); 76 | handles 77 | .lock() 78 | .map_err(|e| format!("Failed to lock handles: {}", e))? 79 | .insert(filename, handle); 80 | 81 | tasks.push(task); 82 | } 83 | 84 | // Listen for cancel download events 85 | let handle_for_listener = Arc::clone(&handle); 86 | let listener = handle.listen(events::CANCEL_DOWNLOAD, move |event| { 87 | let filename = event.payload().replace("\"", ""); // Remove quotes 88 | if let Ok(mut handles) = handles.lock() { 89 | if let Some(handle) = handles.remove(&filename) { 90 | handle.abort(); 91 | let payload = events::DownloadFileAborted { 92 | name: filename.clone(), 93 | reason: "Cancelled by user".to_string(), 94 | }; 95 | handle_for_listener 96 | .as_ref() 97 | .emit(events::DOWNLOAD_FILE_ABORTED, payload.clone()) 98 | .ok(); 99 | info!("Download cancelled for file: {}", filename); 100 | } 101 | } 102 | }); 103 | 104 | // Wait for all tasks to complete 105 | for task in tasks { 106 | if let Err(err) = task.await { 107 | error!("Failed to await task: {}", err); 108 | } 109 | } 110 | 111 | // Emit downloads completion event 112 | handle 113 | .as_ref() 114 | .emit(events::DOWNLOAD_ALL_COMPLETE, ()) 115 | .map_err(|e| format!("Failed to emit completion event: {}", e))?; 116 | 117 | // Unlisten to the cancel download event 118 | handle.as_ref().unlisten(listener); 119 | Ok(()) 120 | } 121 | 122 | async fn download_file( 123 | handle: &AppHandle, 124 | file: files::File, 125 | export_dir: &PathBuf, 126 | remote_node_addr: NodeAddr, 127 | ) -> Result<(), String> { 128 | info!("Started downloading file: {}", file.name); 129 | let state = handle.state::(); 130 | 131 | let iroh = match cfg!(debug_assertions) { 132 | true => &state.iroh_debug, 133 | false => state.iroh(), 134 | }; 135 | 136 | let blobs = &iroh.blobs; 137 | 138 | let dest = export_dir.join(&file.name); 139 | 140 | // Check if file exists before starting download 141 | if dest.exists() { 142 | let err = format!("File already exists"); 143 | handle 144 | .emit( 145 | events::DOWNLOAD_FILE_ERROR, 146 | events::DownloadFileError { 147 | name: file.name.clone(), 148 | error: err.clone(), 149 | }, 150 | ) 151 | .ok(); 152 | return Err(err); 153 | } 154 | 155 | let mut r = blobs 156 | .download(file.hash, remote_node_addr) 157 | .await 158 | .map_err(|e| format!("Failed to download file: {}", e))?; 159 | 160 | let mut last_offset = 0; 161 | let mut timestamp = Instant::now(); 162 | let mut size: u64 = 0; 163 | let mut throttle = utils::Throttle::new(std::time::Duration::from_millis(100)); 164 | 165 | use DownloadProgress as DP; 166 | while let Some(progress) = r.next().await { 167 | match progress { 168 | Ok(p) => match p { 169 | DP::FoundLocal { size: s, .. } => { 170 | info!("Found Local: {}", file.name); 171 | size = s.value(); 172 | } 173 | 174 | DP::Found { size: s, id, .. } => { 175 | info!("Found: {}", id); 176 | size = s; 177 | } 178 | 179 | DP::Progress { offset, .. } if throttle.is_free() => { 180 | let now = Instant::now(); 181 | let elapsed = timestamp.elapsed(); 182 | let speed = if elapsed.as_micros() > 0 { 183 | (offset - last_offset) as f32 / elapsed.as_micros() as f32 184 | } else { 185 | 0.0 186 | }; 187 | timestamp = now; 188 | last_offset = offset; 189 | 190 | if size > 0 { 191 | let percentage = (offset as f32 / size as f32) * 100.0; 192 | let payload = events::DownloadFileProgress { 193 | name: file.name.clone(), 194 | progress: percentage, 195 | speed, 196 | }; 197 | handle.emit(events::DOWNLOAD_FILE_PROGRESS, payload).ok(); 198 | } 199 | } 200 | 201 | DP::AllDone(..) => { 202 | info!("All Done: {}", file.name); 203 | break; 204 | } 205 | 206 | e => warn!("Unhandled download event: {:?}", e), 207 | }, 208 | 209 | Err(e) => { 210 | handle 211 | .emit( 212 | events::DOWNLOAD_FILE_ERROR, 213 | events::DownloadFileError { 214 | name: file.name.clone(), 215 | error: e.to_string(), 216 | }, 217 | ) 218 | .ok(); 219 | return Err(format!("Error during download: {}", e)); 220 | } 221 | } 222 | } 223 | // Export the downloaded file 224 | blobs 225 | .export( 226 | file.hash, 227 | dest.clone(), 228 | ExportFormat::Blob, 229 | ExportMode::Copy, 230 | ) 231 | .await 232 | .map_err(|e| format!("Error exporting file: {}", e))? 233 | .finish() 234 | .await 235 | .map_err(|e| format!("Error finishing export: {}", e))?; 236 | 237 | info!("Exported file to: {}", file.name); 238 | 239 | // Emit completion event 240 | handle 241 | .emit( 242 | events::DOWNLOAD_FILE_COMPLETED, 243 | events::DownloadFileCompleted { 244 | name: file.name.clone(), 245 | path: dest.display().to_string(), 246 | }, 247 | ) 248 | .ok(); 249 | 250 | Ok(()) 251 | } 252 | -------------------------------------------------------------------------------- /src-tauri/src/events.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub const APP_LOADED: &str = "APP_LOADED"; 4 | 5 | // DOWNLOAD 6 | pub const DOWNLOAD_FILE_ADDED: &str = "DOWNLOAD_FILE_ADDED"; 7 | pub const DOWNLOAD_FILE_PROGRESS: &str = "DOWNLOAD_FILE_PROGRESS"; 8 | pub const DOWNLOAD_FILE_COMPLETED: &str = "DOWNLOAD_FILE_COMPLETED"; 9 | pub const DOWNLOAD_ALL_COMPLETE: &str = "DOWNLOAD_ALL_COMPLETE"; 10 | pub const DOWNLOAD_FILE_ERROR: &str = "DOWNLOAD_FILE_ERROR"; 11 | pub const DOWNLOAD_FILE_ABORTED: &str = "DOWNLOAD_FILE_ABORTED"; 12 | pub const CANCEL_DOWNLOAD: &str = "CANCEL_DOWNLOAD"; 13 | 14 | #[derive(Debug, Clone, Serialize)] 15 | pub struct DownloadFileAdded { 16 | pub name: String, 17 | pub icon: String, 18 | pub size: u64, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize)] 22 | pub struct DownloadFileProgress { 23 | pub name: String, 24 | pub progress: f32, 25 | pub speed: f32, 26 | } 27 | 28 | #[derive(Debug, Clone, Serialize)] 29 | pub struct DownloadFileCompleted { 30 | pub name: String, 31 | pub path: String, 32 | } 33 | 34 | #[derive(Debug, Clone, Serialize)] 35 | pub struct DownloadFileAborted { 36 | pub name: String, 37 | pub reason: String, 38 | } 39 | 40 | #[derive(Debug, Clone, Serialize)] 41 | pub struct DownloadFileError { 42 | pub name: String, 43 | pub error: String, 44 | } 45 | 46 | // UPLOAD 47 | pub const UPLOAD_FILE_ADDED: &str = "UPLOAD_FILE_ADDED"; 48 | pub const UPLOAD_FILE_PROGRESS: &str = "UPLOAD_FILE_PROGRESS"; 49 | pub const UPLOAD_FILE_COMPLETED: &str = "UPLOAD_FILE_COMPLETED"; 50 | pub const UPLOAD_FILE_REMOVED: &str = "UPLOAD_FILE_REMOVED"; 51 | #[allow(unused)] 52 | pub const UPLOAD_FILE_ERROR: &str = "UPLOAD_FILE_ERROR"; 53 | 54 | #[derive(Debug, Clone, Serialize)] 55 | pub struct UploadFileAdded { 56 | pub name: String, 57 | pub icon: String, 58 | pub path: String, 59 | pub size: u64, 60 | } 61 | 62 | #[derive(Debug, Clone, Serialize)] 63 | pub struct UploadFileProgress { 64 | pub path: String, 65 | pub progress: f32, 66 | } 67 | 68 | #[derive(Debug, Clone, Serialize)] 69 | pub struct UploadFileRemoved { 70 | pub name: String, 71 | } 72 | 73 | #[derive(Debug, Clone, Serialize)] 74 | pub struct UploadFileCompleted { 75 | pub name: String, 76 | } 77 | 78 | #[derive(Debug, Clone, Serialize)] 79 | pub struct UploadFileError { 80 | pub name: String, 81 | pub error: String, 82 | } 83 | 84 | // REMOVE_FILE 85 | -------------------------------------------------------------------------------- /src-tauri/src/file_operations/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::files::{self}; 2 | use crate::state::AppState; 3 | use crate::{events, state::State, utils}; 4 | use iroh_blobs::rpc::client::blobs::WrapOption; 5 | use iroh_blobs::{provider::AddProgress, util::SetTagOption}; 6 | use n0_future::stream::StreamExt; 7 | use serde::Serialize; 8 | use std::path::PathBuf; 9 | use std::sync::Arc; 10 | use std::time::Duration; 11 | use tauri::{AppHandle, Emitter, Manager}; 12 | use tokio::time::sleep; 13 | use tracing::{debug, error, info, warn}; 14 | 15 | #[derive(Debug, Serialize)] 16 | pub struct ValidatedFile { 17 | name: String, 18 | icon: String, 19 | size: u64, 20 | path: String, 21 | } 22 | 23 | #[tauri::command] 24 | pub async fn validate_files(paths: Vec) -> Result, String> { 25 | let mut files = Vec::new(); 26 | 27 | let tasks = paths 28 | .into_iter() 29 | .map(|path| tokio::spawn(async move { validate_file(&path) })); 30 | 31 | for task in tasks { 32 | let task = task.await.map_err(|e| format!("Task error: {}", e))?; 33 | match task { 34 | Ok(file) => files.push(file), 35 | Err(e) => error!(e), 36 | } 37 | } 38 | 39 | Ok(files) 40 | } 41 | 42 | fn validate_file(path: &str) -> Result { 43 | let original_path = path.to_string(); 44 | 45 | let path = utils::validate_file_path(path)?; 46 | let icon = utils::get_file_icon(original_path.clone()); 47 | let icon = match icon { 48 | Ok(icon) => icon, 49 | Err(e) => { 50 | warn!("{}. Using default value", e); 51 | String::new() 52 | } 53 | }; 54 | 55 | let metadata = path 56 | .metadata() 57 | .map_err(|e| format!("Failed to get metadata: {:?}", e))?; 58 | 59 | let size = metadata.len(); 60 | let name = utils::file_name_from_path(&path)?; 61 | 62 | let file = ValidatedFile { 63 | name, 64 | icon, 65 | size, 66 | path: original_path, 67 | }; 68 | 69 | Ok(file) 70 | } 71 | 72 | #[tauri::command] 73 | pub async fn add_file(state: State<'_>, path: String, handle: AppHandle) -> Result<(), String> { 74 | info!("Adding file: {}", path); 75 | 76 | let original_path = path.clone(); 77 | let path = utils::validate_file_path(path)?; 78 | let file_name = utils::file_name_from_path(&path)?; 79 | { 80 | let files = state.files().await; 81 | if files.has_file(&file_name) { 82 | let err = format!("Duplicate file names not allowed.",); 83 | error!("{}", err); 84 | return Err(err); 85 | } 86 | } 87 | 88 | let icon = utils::get_file_icon(original_path.clone()); 89 | let icon = match icon { 90 | Ok(i) => i, 91 | Err(e) => { 92 | warn!("{}. Using default value.", e); 93 | String::new() 94 | } 95 | }; 96 | 97 | let mut r = state 98 | .iroh() 99 | .blobs 100 | .add_from_path(path.clone(), true, SetTagOption::Auto, WrapOption::NoWrap) 101 | .await 102 | .map_err(|e| format!("Failed to add file: {:?}", e))?; 103 | 104 | let mut size: u64 = 0; 105 | let mut throttle = utils::Throttle::new(Duration::from_millis(32)); 106 | 107 | let mut hash = iroh_blobs::Hash::EMPTY; 108 | while let Some(progress) = r.next().await { 109 | match progress { 110 | Ok(p) => match p { 111 | AddProgress::Found { 112 | size: file_size, .. 113 | } => { 114 | info!("Found file: {}", file_name); 115 | size = file_size; 116 | 117 | let payload = events::UploadFileAdded { 118 | name: file_name.clone(), 119 | icon: icon.clone(), 120 | path: original_path.to_string(), 121 | size, 122 | }; 123 | handle.emit(events::UPLOAD_FILE_ADDED, payload).ok(); 124 | } 125 | AddProgress::Progress { offset, .. } => { 126 | if throttle.is_free() { 127 | let progress_percent = (offset as f32 / size as f32) * 100.0; 128 | debug!("Progress: {}", progress_percent); 129 | let payload = events::UploadFileProgress { 130 | path: file_name.clone(), 131 | progress: progress_percent, 132 | }; 133 | handle.emit(events::UPLOAD_FILE_PROGRESS, payload).ok(); 134 | } 135 | } 136 | AddProgress::Done { hash: _hash, .. } => { 137 | hash = _hash; 138 | info!("File uploaded: {}", original_path); 139 | let payload = events::UploadFileCompleted { 140 | name: file_name.clone(), 141 | }; 142 | handle.emit(events::UPLOAD_FILE_COMPLETED, payload).ok(); 143 | } 144 | AddProgress::Abort { .. } => { 145 | info!("Upload aborted: {}", original_path); 146 | } 147 | AddProgress::AllDone { .. } => { 148 | break; 149 | } 150 | }, 151 | Err(e) => { 152 | error!("Failed to add file: {:?}", e); 153 | } 154 | } 155 | } 156 | 157 | let file = files::File { 158 | name: file_name, 159 | icon, 160 | size, 161 | hash, 162 | }; 163 | let mut files = state.files().await; 164 | files.add_file(file); 165 | 166 | Ok(()) 167 | } 168 | 169 | 170 | #[tauri::command] 171 | pub async fn remove_file(path: String, handle: AppHandle) -> Result<(), String> { 172 | let state = handle.state::(); 173 | 174 | let path = PathBuf::from(path); 175 | let name = utils::file_name_from_path(&path)?; 176 | info!("Removing file : {}", name); 177 | 178 | let hash = { 179 | let files = state.files().await; 180 | files 181 | .get(&name) 182 | .ok_or_else(|| format!("File not found: {}", name))? 183 | .hash 184 | }; 185 | 186 | state 187 | .iroh() 188 | .blobs 189 | .delete_blob(hash) 190 | .await 191 | .map_err(|e| format!("Failed to delete blob: {}", e))?; 192 | 193 | { 194 | let mut files = state.files().await; 195 | files.remove_file(&name); 196 | } 197 | 198 | handle 199 | .emit( 200 | events::UPLOAD_FILE_REMOVED, 201 | events::UploadFileRemoved { 202 | name: name.to_owned(), 203 | }, 204 | ) 205 | .ok(); 206 | 207 | Ok(()) 208 | } 209 | 210 | #[tauri::command] 211 | pub async fn remove_all_files(state: State<'_>, handle: AppHandle) -> Result<(), String> { 212 | info!("Removing all files"); 213 | let mut files = state.files().await; 214 | let handle = Arc::new(handle); 215 | 216 | let tasks = files 217 | .iter() 218 | .map(|(_, file)| { 219 | let handle = Arc::clone(&handle); 220 | let file = file.clone(); 221 | let hash = file.hash; 222 | tokio::spawn(async move { 223 | let state = handle.state::(); 224 | state 225 | .iroh() 226 | .blobs 227 | .delete_blob(hash) 228 | .await 229 | .map_err(|e| format!("Failed to delete blob: {}", e))?; 230 | 231 | handle 232 | .emit( 233 | events::UPLOAD_FILE_REMOVED, 234 | events::UploadFileRemoved { 235 | name: file.name.clone(), 236 | }, 237 | ) 238 | .ok(); 239 | 240 | info!("File {} removed successfully", hash); 241 | sleep(Duration::from_secs(2)).await; 242 | 243 | Ok::<(), String>(()) 244 | }) 245 | }) 246 | .collect::>(); 247 | 248 | for task in tasks { 249 | let result = task.await.map_err(|e| format!("Task error: {}", e))?; 250 | if let Err(e) = result { 251 | error!("Error removing file: {}", e); 252 | } 253 | } 254 | 255 | // Remove all generated header files 256 | for ticket in state.header_tickets.lock().await.iter() { 257 | state 258 | .iroh() 259 | .blobs 260 | .delete_blob(ticket.hash()) 261 | .await 262 | .map_err(|e| format!("Failed to delete blob: {}", e))?; 263 | info!("Ticket {} removed successfully", ticket); 264 | } 265 | 266 | files.clear(); 267 | info!("All files removed successfully"); 268 | Ok(()) 269 | } 270 | -------------------------------------------------------------------------------- /src-tauri/src/files.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | ops::{Deref, DerefMut}, 4 | str::FromStr, 5 | }; 6 | 7 | use iroh_blobs::Hash; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::iroh::GossipTicket; 11 | 12 | const VERSION: u32 = 1; 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize)] 15 | pub struct File { 16 | pub name: String, 17 | pub icon: String, 18 | pub size: u64, 19 | pub hash: Hash, 20 | } 21 | 22 | #[derive(Debug, Clone, Serialize, Deserialize)] 23 | pub struct Files { 24 | pub version: u32, 25 | pub files: HashMap, 26 | gossip_ticket: GossipTicket, 27 | } 28 | 29 | impl Deref for Files { 30 | type Target = HashMap; 31 | 32 | fn deref(&self) -> &Self::Target { 33 | &self.files 34 | } 35 | } 36 | 37 | impl DerefMut for Files { 38 | fn deref_mut(&mut self) -> &mut Self::Target { 39 | &mut self.files 40 | } 41 | } 42 | 43 | impl Files { 44 | pub fn new(ticket: GossipTicket) -> Self { 45 | Self { 46 | version: VERSION, 47 | gossip_ticket: ticket, 48 | files: HashMap::new(), 49 | } 50 | } 51 | 52 | pub fn add_file(&mut self, file: File) { 53 | self.files.insert(file.name.clone(), file); 54 | } 55 | 56 | pub fn remove_file(&mut self, name: &str) { 57 | self.files.remove(name); 58 | } 59 | 60 | pub fn has_file(&self, name: &str) -> bool { 61 | self.files.contains_key(name) 62 | } 63 | 64 | pub fn to_bytes(&self) -> Vec { 65 | serde_json::to_vec(self).expect("Infallible") 66 | } 67 | 68 | pub fn from_bytes(bytes: &[u8]) -> Result { 69 | serde_json::from_slice(bytes).map_err(|e| format!("Failed to parse bytes: {}", e)) 70 | } 71 | } 72 | 73 | impl ToString for Files { 74 | fn to_string(&self) -> String { 75 | let text = data_encoding::BASE32.encode(&self.to_bytes()); 76 | text 77 | } 78 | } 79 | 80 | impl FromStr for Files { 81 | type Err = String; 82 | fn from_str(s: &str) -> Result { 83 | println!("{s}"); 84 | let bytes = data_encoding::BASE32 85 | .decode(s.as_bytes()) 86 | .map_err(|e| format!("Failed to decode base32: {}", e))?; 87 | 88 | let files = Self::from_bytes(&bytes)?; 89 | 90 | if files.version != VERSION { 91 | return Err(format!( 92 | "Version mismatch: expected {}, got {}", 93 | VERSION, files.version 94 | )); 95 | } 96 | Ok(files) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src-tauri/src/iroh.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::Deref, path::PathBuf, str::FromStr}; 2 | 3 | use anyhow::Result; 4 | use iroh::{protocol::Router, NodeAddr, NodeId}; 5 | use iroh_gossip::{ 6 | net::{Gossip, GossipReceiver, GossipSender}, 7 | proto::TopicId, 8 | }; 9 | use quic_rpc::transport::flume::FlumeConnector; 10 | use serde::{Deserialize, Serialize}; 11 | 12 | pub type BlobsClient = iroh_blobs::rpc::client::blobs::Client< 13 | FlumeConnector, 14 | >; 15 | 16 | #[derive(Clone, Debug, Deserialize, Serialize)] 17 | pub struct GossipTicket { 18 | pub topic_id: TopicId, 19 | pub node_id: NodeId, 20 | } 21 | 22 | impl GossipTicket { 23 | pub fn new(topic_id: TopicId, node_id: NodeId) -> Self { 24 | Self { topic_id, node_id } 25 | } 26 | } 27 | 28 | impl GossipTicket { 29 | fn from_bytes(bytes: &[u8]) -> Result { 30 | serde_json::from_slice(bytes).map_err(Into::into) 31 | } 32 | 33 | fn to_bytes(&self) -> Vec { 34 | serde_json::to_vec(self).expect("Infallible") 35 | } 36 | } 37 | 38 | impl FromStr for GossipTicket { 39 | type Err = anyhow::Error; 40 | fn from_str(s: &str) -> Result { 41 | let bytes = data_encoding::BASE32 42 | .decode(s.as_bytes()) 43 | .map_err(|_| anyhow::anyhow!("Invalid base32 string"))?; 44 | Self::from_bytes(&bytes) 45 | } 46 | } 47 | 48 | impl ToString for GossipTicket { 49 | fn to_string(&self) -> String { 50 | let mut text = data_encoding::BASE32.encode(&self.to_bytes()); 51 | text.make_ascii_lowercase(); 52 | text 53 | } 54 | } 55 | 56 | #[derive(Debug)] 57 | pub struct GossipClient { 58 | pub client: Gossip, 59 | ticket: GossipTicket, 60 | channel: GossipChannel, 61 | } 62 | 63 | impl Deref for GossipClient { 64 | type Target = Gossip; 65 | 66 | fn deref(&self) -> &Self::Target { 67 | &self.client 68 | } 69 | } 70 | 71 | impl GossipClient { 72 | pub async fn new(gossip: Gossip, node_id: NodeId) -> Result { 73 | let topic_id = TopicId::from_bytes(rand::random()); 74 | let ticket = GossipTicket::new(topic_id, node_id); 75 | let (sender, receiver) = gossip.subscribe(topic_id, vec![])?.split(); 76 | let gossip_chan = GossipChannel { 77 | sender, 78 | receiver: Some(receiver), 79 | }; 80 | 81 | Ok(Self { 82 | client: gossip, 83 | ticket, 84 | channel: gossip_chan, 85 | }) 86 | } 87 | 88 | pub fn channel_mut(&mut self) -> &mut GossipChannel { 89 | &mut self.channel 90 | } 91 | 92 | pub fn ticket(&self) -> &GossipTicket { 93 | &self.ticket 94 | } 95 | } 96 | 97 | #[derive(Debug)] 98 | pub struct GossipChannel { 99 | sender: GossipSender, 100 | receiver: Option, 101 | } 102 | 103 | impl Deref for GossipChannel { 104 | type Target = GossipSender; 105 | 106 | fn deref(&self) -> &Self::Target { 107 | &self.sender 108 | } 109 | } 110 | 111 | impl GossipChannel { 112 | pub fn take_receiver(&mut self) -> Result { 113 | self.receiver 114 | .take() 115 | .ok_or(anyhow::anyhow!("Receiver already taken")) 116 | } 117 | } 118 | 119 | #[derive(Debug)] 120 | pub struct Iroh { 121 | #[allow(dead_code)] 122 | router: Router, 123 | pub blobs: BlobsClient, 124 | pub node_addr: NodeAddr, 125 | pub gossip: GossipClient, 126 | } 127 | 128 | impl Iroh { 129 | pub async fn new(path: PathBuf) -> Result { 130 | // create dir if it doesn't already exist 131 | tokio::fs::create_dir_all(&path).await?; 132 | 133 | // create endpoint 134 | let endpoint = iroh::Endpoint::builder().discovery_n0().bind().await?; 135 | 136 | // build the protocol router 137 | let mut builder = iroh::protocol::Router::builder(endpoint); 138 | 139 | // add iroh blobs 140 | let blobs = iroh_blobs::net_protocol::Blobs::persistent(&path) 141 | .await? 142 | .build(builder.endpoint()); 143 | builder = builder.accept(iroh_blobs::ALPN, blobs.clone()); 144 | 145 | // add iroh gossip 146 | let gossip = Gossip::builder().spawn(builder.endpoint().clone()).await?; 147 | builder = builder.accept(iroh_gossip::ALPN, gossip.clone()); 148 | 149 | let node_addr = builder.endpoint().node_addr().await?; 150 | let router = builder.spawn().await?; 151 | let blobs = blobs.client().clone(); 152 | let gossip = GossipClient::new(gossip, node_addr.node_id).await?; 153 | 154 | Ok(Self { 155 | node_addr, 156 | router, 157 | blobs, 158 | gossip, 159 | }) 160 | } 161 | 162 | #[allow(dead_code)] 163 | pub async fn shutdown(&self) -> Result<(), String> { 164 | self.router.shutdown().await.map_err(|e| e.to_string()) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | mod download; 4 | mod events; 5 | mod file_operations; 6 | mod files; 7 | mod iroh; 8 | mod state; 9 | mod theme; 10 | mod ticket; 11 | mod utils; 12 | 13 | use log::LevelFilter; 14 | use state::user_data::{self, User}; 15 | use std::{fs, time::Duration}; 16 | use tauri::{AppHandle, Emitter, Manager}; 17 | use tauri_plugin_log::{Target, TargetKind}; 18 | use tokio::time; 19 | use tracing::{error, info}; 20 | 21 | const DATA_DIR: &str = ".database"; 22 | #[cfg(debug_assertions)] 23 | const DATA_DIR_DEBUG: &str = ".database-test"; 24 | 25 | async fn setup(handle: tauri::AppHandle) -> anyhow::Result<()> { 26 | let app_data_dir = match cfg!(debug_assertions) { 27 | true => handle.path().download_dir()?.join(".sendit"), 28 | false => handle.path().app_local_data_dir()?, 29 | }; 30 | 31 | let data_dir = app_data_dir.join(DATA_DIR); 32 | fs::create_dir_all(&data_dir)?; 33 | info!("Data directory created at: {}", data_dir.display()); 34 | 35 | let mut iroh = iroh::Iroh::new(data_dir).await?; 36 | let channel = &mut iroh.gossip.channel_mut(); 37 | #[allow(unused)] 38 | let rx = channel.take_receiver()?; 39 | 40 | #[cfg(debug_assertions)] 41 | let iroh_debug = { 42 | let data_dir = app_data_dir.join(DATA_DIR_DEBUG); 43 | fs::create_dir_all(&data_dir)?; 44 | info!( 45 | "Data directory for debug created at: {}", 46 | data_dir.display() 47 | ); 48 | 49 | iroh::Iroh::new(data_dir).await? 50 | }; 51 | 52 | let cfg_path = utils::get_config_dir(&handle) 53 | .map_err(|e| anyhow::anyhow!(e))? 54 | .join(user_data::CONFIG_FILE_NAME); 55 | 56 | let user = User::from_config(cfg_path).ok(); 57 | handle.manage(state::AppState::new(user, iroh, iroh_debug)); 58 | 59 | Ok(()) 60 | } 61 | 62 | pub fn notify_app_loaded(handle: AppHandle) { 63 | tokio::spawn(async move { 64 | time::sleep(Duration::from_millis(300)).await; 65 | handle.emit(events::APP_LOADED, ()).ok(); 66 | }); 67 | } 68 | 69 | fn main() { 70 | tauri::Builder::default() 71 | .plugin( 72 | tauri_plugin_log::Builder::new() 73 | .targets(vec![ 74 | Target::new(TargetKind::Stdout), 75 | Target::new(TargetKind::Webview), 76 | ]) 77 | .level(LevelFilter::Error) 78 | .level(LevelFilter::Warn) 79 | .level(LevelFilter::Info) 80 | .build(), 81 | ) 82 | .plugin(tauri_plugin_windows_version::init()) // WINDOWS VERSION 83 | .plugin(tauri_plugin_clipboard_manager::init()) // CLIPBOARD 84 | .plugin(tauri_plugin_dialog::init()) // DIALOG 85 | .plugin(tauri_plugin_opener::init()) // FILE OPENER 86 | .setup(|app| { 87 | let handle = app.handle().clone(); 88 | let window = handle.get_webview_window("main").unwrap(); 89 | 90 | #[cfg(debug_assertions)] // Only on Dev environment 91 | window.open_devtools(); 92 | 93 | #[cfg(target_os = "windows")] 94 | { 95 | let res = window_vibrancy::apply_mica(&window, None); 96 | if let Err(err) = res { 97 | error!("Error applying mica: {}", err); 98 | } 99 | } 100 | 101 | let handle_clone = handle.clone(); 102 | tauri::async_runtime::spawn(async move { 103 | if let Err(err) = setup(handle_clone).await { 104 | error!("Error setting up application: {}", err); 105 | return; 106 | } else { 107 | notify_app_loaded(handle); 108 | } 109 | }); 110 | 111 | Ok(()) 112 | }) 113 | .invoke_handler(tauri::generate_handler![ 114 | file_operations::add_file, 115 | file_operations::remove_file, 116 | file_operations::remove_all_files, 117 | file_operations::validate_files, 118 | download::download_header, 119 | ticket::generate_ticket, 120 | theme::set_theme, 121 | state::get_user, 122 | state::update_user, 123 | state::user_data::is_onboarded, 124 | state::app_loaded 125 | ]) 126 | .run(tauri::generate_context!()) 127 | .expect("Error while running tauri application"); 128 | } 129 | -------------------------------------------------------------------------------- /src-tauri/src/state/mod.rs: -------------------------------------------------------------------------------- 1 | use iroh_blobs::ticket::BlobTicket; 2 | use tauri::{AppHandle, Manager}; 3 | use tokio::sync::{Mutex, MutexGuard}; 4 | use user_data::User; 5 | 6 | pub mod user_data; 7 | 8 | use crate::files; 9 | use crate::iroh; 10 | 11 | #[derive(Debug)] 12 | pub struct AppState { 13 | #[allow(unused)] 14 | #[cfg(debug_assertions)] 15 | pub iroh_debug: iroh::Iroh, 16 | 17 | pub user: Mutex>, 18 | pub iroh: iroh::Iroh, 19 | pub files: Mutex, 20 | pub header_tickets: Mutex>, 21 | } 22 | 23 | impl AppState { 24 | pub fn new( 25 | user: Option, 26 | iroh: iroh::Iroh, 27 | #[cfg(debug_assertions)] iroh_debug: iroh::Iroh, 28 | ) -> Self { 29 | let ticket = iroh.gossip.ticket().to_owned(); 30 | Self { 31 | user: Mutex::new(user), 32 | iroh_debug, 33 | iroh, 34 | files: Mutex::new(files::Files::new(ticket)), 35 | header_tickets: Mutex::new(Vec::new()), 36 | } 37 | } 38 | 39 | pub fn iroh(&self) -> &iroh::Iroh { 40 | &self.iroh 41 | } 42 | 43 | pub async fn files(&self) -> MutexGuard<'_, files::Files> { 44 | self.files.lock().await 45 | } 46 | } 47 | 48 | pub type State<'a> = tauri::State<'a, AppState>; 49 | 50 | #[tauri::command] 51 | pub async fn get_user(state: State<'_>) -> Result, String> { 52 | let user = state.user.lock().await; 53 | Ok(user.clone()) 54 | } 55 | 56 | #[tauri::command] 57 | pub async fn update_user(state: State<'_>, user: User, app: AppHandle) -> Result<(), String> { 58 | let cfg_path = crate::utils::get_config_dir(&app)?.join(user_data::CONFIG_FILE_NAME); 59 | 60 | if let Err(e) = user.save(cfg_path) { 61 | return Err(format!("Failed to save user data: {}", e)); 62 | } 63 | 64 | let mut state_user = state.user.lock().await; 65 | *state_user = Some(user); 66 | 67 | Ok(()) 68 | } 69 | 70 | #[tauri::command] 71 | pub fn app_loaded(app: AppHandle) -> bool { 72 | match app.try_state::() { 73 | None => false, 74 | _ => true, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src-tauri/src/state/user_data.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{self}, 3 | path::PathBuf, 4 | }; 5 | 6 | use anyhow::Result; 7 | use log::info; 8 | use serde::{Deserialize, Serialize}; 9 | use tauri::{AppHandle, Manager}; 10 | 11 | pub const CONFIG_FILE_NAME: &str = "user-data.json"; 12 | 13 | #[derive(Clone, Debug, Deserialize, Serialize)] 14 | pub struct User { 15 | pub name: String, 16 | pub avatar: u8, 17 | } 18 | 19 | impl User { 20 | pub fn from_config(path: PathBuf) -> Result { 21 | let contents = fs::read_to_string(path) 22 | .map_err(|e| anyhow::anyhow!("Failed to read config file: {}", e))?; 23 | 24 | let user: User = serde_json::from_str(&contents) 25 | .map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?; 26 | 27 | Ok(user) 28 | } 29 | 30 | pub fn save(&self, path: PathBuf) -> Result<()> { 31 | println!("{}", path.display()); 32 | let contents = serde_json::to_string(self) 33 | .map_err(|e| anyhow::anyhow!("Failed to serialize user data: {}", e))?; 34 | 35 | if !path.exists() { 36 | fs::create_dir_all(&path)? 37 | } 38 | 39 | fs::write(path, contents) 40 | .map_err(|e| anyhow::anyhow!("Failed to write config file: {}", e))?; 41 | 42 | info!("User data saved successfully"); 43 | 44 | Ok(()) 45 | } 46 | } 47 | 48 | #[tauri::command] 49 | pub fn is_onboarded(app: AppHandle) -> bool { 50 | let cfg_dir = app.path().config_dir(); 51 | 52 | match cfg_dir { 53 | Err(_) => false, 54 | Ok(path) => path.join(CONFIG_FILE_NAME).exists(), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src-tauri/src/theme/mod.rs: -------------------------------------------------------------------------------- 1 | use tauri::{AppHandle, Manager}; 2 | 3 | #[tauri::command] 4 | #[allow(unused)] 5 | pub fn set_theme(theme: String, handle: AppHandle) -> Result<(), String> { 6 | #[cfg(target_os = "windows")] 7 | { 8 | let window = handle 9 | .get_webview_window("main") 10 | .ok_or_else(|| "Failed to get window")?; 11 | 12 | let res = match theme.as_str() { 13 | "dark" => window_vibrancy::apply_mica(&window, Some(true)), 14 | "light" => window_vibrancy::apply_mica(&window, Some(false)), 15 | _ => window_vibrancy::apply_mica(&window, None), 16 | }; 17 | 18 | if let Err(err) = res { 19 | tracing::error!("Error setting theme: {}", err); 20 | } 21 | } 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src-tauri/src/ticket/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::state::State; 2 | use log::info; 3 | 4 | #[tauri::command] 5 | pub async fn generate_ticket(state: State<'_>) -> Result { 6 | info!("Generating ticket"); 7 | let files = state.files().await; 8 | let header_str = files.to_string(); 9 | 10 | let res = state 11 | .iroh() 12 | .blobs 13 | .add_bytes(header_str) 14 | .await 15 | .map_err(|e| format!("Failed to add header file: {}", e))?; 16 | 17 | let ticket = iroh_blobs::ticket::BlobTicket::new(state.iroh().node_addr.clone(), res.hash, res.format) 18 | .map_err(|e| format!("Failed to create ticket: {}", e))?; 19 | 20 | let mut tickets = state.header_tickets.lock().await; 21 | tickets.push(ticket.clone()); 22 | 23 | Ok(ticket.to_string()) 24 | } -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | use data_encoding::BASE64; 7 | use file_icon_provider::get_file_icon as get_file_icon_pkg; 8 | use image::{DynamicImage, RgbaImage}; 9 | use iroh_blobs::ticket::BlobTicket; 10 | use tauri::{AppHandle, Manager}; 11 | 12 | use crate::iroh::BlobsClient; 13 | 14 | pub fn file_name_from_path(path: impl AsRef) -> Result { 15 | let name = path 16 | .as_ref() 17 | .file_name() 18 | .ok_or("Failed to get file name")? 19 | .to_string_lossy() 20 | .to_string(); 21 | Ok(name) 22 | } 23 | 24 | pub fn get_download_dir(handle: &AppHandle) -> Result { 25 | let dir = handle 26 | .path() 27 | .download_dir() 28 | .map_err(|_| "Failed to get download directory")? 29 | .join("sendit"); 30 | Ok(dir) 31 | } 32 | 33 | pub fn get_config_dir(handle: &AppHandle) -> Result { 34 | let dir = handle 35 | .path() 36 | .app_local_data_dir() 37 | .map_err(|_| "Failed to get config directory")?; 38 | Ok(dir) 39 | } 40 | 41 | pub struct Throttle { 42 | last_emit: Instant, 43 | interval: Duration, 44 | } 45 | 46 | impl Throttle { 47 | pub fn new(interval: Duration) -> Self { 48 | Self { 49 | last_emit: Instant::now() - interval, 50 | interval, 51 | } 52 | } 53 | 54 | pub fn is_free(&mut self) -> bool { 55 | let now = Instant::now(); 56 | if now.duration_since(self.last_emit) >= self.interval { 57 | self.last_emit = now; 58 | return true; 59 | } 60 | false 61 | } 62 | } 63 | 64 | pub fn get_file_icon(path: impl AsRef) -> Result { 65 | let icon = get_file_icon_pkg(path, 64).map_err(|e| format!("{}", e))?; 66 | let image = RgbaImage::from_raw(icon.width, icon.height, icon.pixels) 67 | .map(DynamicImage::ImageRgba8) 68 | .expect("Failed to convert icon to image"); 69 | 70 | let mut png_data: Vec = Vec::new(); 71 | let mut cursor = std::io::Cursor::new(&mut png_data); 72 | image 73 | .write_to(&mut cursor, image::ImageFormat::Png) 74 | .map_err(|e| format!("Failed to encode image to PNG: {}", e))?; 75 | 76 | let png_string = format!( 77 | "data:image/png;base64,{}", 78 | BASE64.encode(png_data.as_slice()) 79 | ); 80 | Ok(png_string) 81 | } 82 | 83 | pub enum LogLevel { 84 | #[allow(dead_code)] 85 | Debug, 86 | #[allow(dead_code)] 87 | Info, 88 | #[allow(dead_code)] 89 | Warn, 90 | Error, 91 | } 92 | 93 | #[macro_export] 94 | macro_rules! log { 95 | ($level:expr, $($arg:tt)*) => {{ 96 | let formatted = format!($($arg)*); 97 | match $level { 98 | LogLevel::Debug => log::debug!("DEBUG: {}", formatted), 99 | LogLevel::Info => log::info!("INFO: {}", formatted), 100 | LogLevel::Warn => log::warn!("WARN: {}", formatted), 101 | LogLevel::Error => log::error!("ERROR: {}", formatted), 102 | } 103 | formatted 104 | }}; 105 | } 106 | 107 | pub fn validate_file_path(path: impl AsRef) -> Result { 108 | let path = path 109 | .as_ref() 110 | .canonicalize() 111 | .map_err(|e| format!("Failed to canonicalize path: {:?}", e))?; 112 | 113 | if !path.exists() { 114 | let err = log!(LogLevel::Error, "File does not exist at path"); 115 | return Err(err); 116 | } 117 | 118 | if path.is_dir() { 119 | let err = log!(LogLevel::Error, "Directory not supported"); 120 | return Err(err); 121 | } 122 | Ok(path) 123 | } 124 | 125 | pub async fn download_and_read_header( 126 | blobs: &BlobsClient, 127 | ticket: BlobTicket, 128 | ) -> Result { 129 | let (node_addr, hash, _) = ticket.into_parts(); 130 | blobs 131 | .download(hash.clone(), node_addr) 132 | .await 133 | .map_err(|e| format!("Failed to download header file: {}", e))? 134 | .finish() 135 | .await 136 | .map_err(|e| format!("Failed to finish downloading header file: {}", e))?; 137 | 138 | let bytes = blobs 139 | .read_to_bytes(hash) 140 | .await 141 | .map_err(|e| format!("Failed to read bytes: {}", e))?; 142 | 143 | let res = String::from_utf8(bytes.to_vec()) 144 | .map_err(|e| format!("Failed to convert bytes to string: {}", e))?; 145 | 146 | Ok(res) 147 | } 148 | 149 | use crate::iroh::Iroh; 150 | #[allow(dead_code)] 151 | pub async fn setup_temp_iroh(suffix: &str, app: &AppHandle) -> Result { 152 | let data_dir = app 153 | .path() 154 | .download_dir() 155 | .map_err(|e| format!("Failed to get temp dir: {:?}", e))? 156 | .join(format!("sendit-{suffix}")); 157 | 158 | let iroh = Iroh::new(data_dir) 159 | .await 160 | .map_err(|e| format!("Failed to initialize iroh: {}", e))?; 161 | 162 | Ok(iroh) 163 | } 164 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "sendit", 4 | "version": "0.4.4", 5 | "identifier": "com.sendit.app", 6 | "build": { 7 | "beforeDevCommand": "npm run dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "npm run build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "SendIt", 16 | "resizable": false, 17 | "decorations": false, 18 | "width": 400, 19 | "height": 600, 20 | "transparent": true, 21 | "windowEffects": { 22 | "effects": ["mica"] 23 | } 24 | } 25 | ], 26 | "security": { 27 | "csp": null 28 | } 29 | }, 30 | "bundle": { 31 | "macOS": { 32 | "signingIdentity": "-" 33 | }, 34 | "active": true, 35 | "targets": ["nsis", "app", "deb"], 36 | "icon": [ 37 | "icons/32x32.png", 38 | "icons/128x128.png", 39 | "icons/128x128@2x.png", 40 | "icons/icon.icns", 41 | "icons/icon.ico" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/assets/avatars/avatar1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar1.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar10.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar11.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar12.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar2.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar3.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar4.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar5.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar6.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar7.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar8.png -------------------------------------------------------------------------------- /src/assets/avatars/avatar9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/avatars/avatar9.png -------------------------------------------------------------------------------- /src/assets/avatars/index.ts: -------------------------------------------------------------------------------- 1 | import avatar1 from './avatar1.png' 2 | import avatar2 from './avatar2.png' 3 | import avatar3 from './avatar3.png' 4 | import avatar4 from './avatar4.png' 5 | import avatar5 from './avatar5.png' 6 | import avatar6 from './avatar6.png' 7 | import avatar7 from './avatar7.png' 8 | import avatar8 from './avatar8.png' 9 | import avatar9 from './avatar9.png' 10 | import avatar10 from './avatar10.png' 11 | import avatar11 from './avatar11.png' 12 | import avatar12 from './avatar12.png' 13 | 14 | export const avatars = [ 15 | avatar1, 16 | avatar2, 17 | avatar3, 18 | avatar4, 19 | avatar5, 20 | avatar6, 21 | avatar7, 22 | avatar8, 23 | avatar9, 24 | avatar10, 25 | avatar11, 26 | avatar12, 27 | ] 28 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frstycodes/sendit/54766504e64fe54c23685f974165921fcf298c9e/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/animated-checkmark.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipTrigger, 5 | } from '@/components/ui/tooltip' 6 | import { Check } from 'lucide-react' 7 | 8 | export function AnimatedCheckMark({ 9 | tooltipContent, 10 | }: { 11 | tooltipContent?: string 12 | }) { 13 | return ( 14 | 15 | 16 |
20 | 21 |
22 |
23 | {!!tooltipContent && {tooltipContent}} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/loader.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils' 2 | import { AnimatePresence, motion } from 'motion/react' 3 | 4 | type LoaderProps = { 5 | className?: string 6 | show?: boolean 7 | } 8 | export function Loader(props: LoaderProps) { 9 | return ( 10 | 11 | {props.show && ( 12 | 21 | )} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/titlebar.tsx: -------------------------------------------------------------------------------- 1 | import { avatars } from '@/assets/avatars' 2 | import { AppState } from '@/state/appstate' 3 | import { cn } from '@/utils' 4 | import { Link, useRouter } from '@tanstack/react-router' 5 | import { getCurrentWindow } from '@tauri-apps/api/window' 6 | import { 7 | ChevronDown, 8 | ChevronLeft, 9 | ChevronRight, 10 | Home, 11 | Minus, 12 | Pen, 13 | X, 14 | } from 'lucide-react' 15 | import { CycleThemeButton } from './toggle-theme' 16 | import { Button } from './ui/button' 17 | import { 18 | DropdownMenu, 19 | DropdownMenuContent, 20 | DropdownMenuItem, 21 | DropdownMenuSeparator, 22 | DropdownMenuTrigger, 23 | } from './ui/dropdown-menu' 24 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' 25 | 26 | const appWindow = getCurrentWindow() 27 | const WINDOW_BUTTONS = [ 28 | { 29 | name: 'Minimize', 30 | icon: , 31 | fn: () => appWindow.minimize(), 32 | }, 33 | { 34 | name: 'Close', 35 | icon: , 36 | buttonClassName: 'hover:bg-rose-500', 37 | fn: () => appWindow.close(), 38 | }, 39 | ] 40 | 41 | export function TitleBar() { 42 | return ( 43 |
44 |
45 | 46 | 47 | 48 |
49 |
50 | 51 |
52 |
53 | {WINDOW_BUTTONS.map((item) => ( 54 | 55 | ))} 56 |
57 |
58 | ) 59 | } 60 | 61 | function UserButton() { 62 | const { user } = AppState.use('user') 63 | if (!user) return null 64 | 65 | const avatar = avatars[user.avatar] 66 | return ( 67 | 68 | 69 | 76 | 77 | 78 |
79 | 80 |

{user.name}

81 |
82 | 83 | 84 | 85 | Edit Profile 86 | 87 | 88 | e.preventDefault()} 91 | className='cursor-pointer' 92 | > 93 | 94 | 95 |
96 |
97 | ) 98 | } 99 | 100 | type NavigationButtonProps = { 101 | direction: 'forward' | 'back' | 'home' 102 | } 103 | 104 | function NavigationButton(props: NavigationButtonProps) { 105 | const router = useRouter() 106 | 107 | const iconMap = { 108 | forward: ChevronRight, 109 | back: ChevronLeft, 110 | home: Home, 111 | } 112 | const labelMap = { 113 | forward: 'Go forward', 114 | back: 'Go back', 115 | home: 'Go home', 116 | } 117 | 118 | const navigateFn = { 119 | forward: () => router.history.forward(), 120 | back: () => router.history.back(), 121 | home: () => router.navigate({ to: '/' }), 122 | } 123 | 124 | const Icon = iconMap[props.direction] 125 | return ( 126 | 127 | 128 | 137 | 138 | {labelMap[props.direction]} 139 | 140 | ) 141 | } 142 | 143 | function WindowButton({ button }: { button: (typeof WINDOW_BUTTONS)[number] }) { 144 | return ( 145 | 157 | ) 158 | } 159 | -------------------------------------------------------------------------------- /src/components/toggle-theme.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@/context/theme.context' 2 | import { Monitor, Moon, Sun } from 'lucide-react' 3 | import { useRef } from 'react' 4 | import { Button } from './ui/button' 5 | import { cn } from '@/utils' 6 | 7 | const STATES = [ 8 | { theme: 'system', icon: Monitor }, 9 | { theme: 'light', icon: Sun }, 10 | { theme: 'dark', icon: Moon }, 11 | ] 12 | 13 | export function CycleThemeButton({ className }: { className?: string }) { 14 | const { theme, setTheme } = useTheme() 15 | const currThemeIdx = useRef( 16 | STATES.findIndex((state) => state.theme === theme), 17 | ) 18 | const currentTheme = STATES[currThemeIdx.current] 19 | 20 | function cycleTheme() { 21 | const newIdx = (currThemeIdx.current + 1) % STATES.length 22 | currThemeIdx.current = newIdx 23 | setTheme(STATES[newIdx].theme as any) 24 | } 25 | 26 | return ( 27 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | import { motion } from 'motion/react' 4 | import * as React from 'react' 5 | 6 | export const buttonStyles = { 7 | default: 8 | 'bg-white/90 dark:bg-white/5 hover:bg-white/10 dark:border-black/30 border-b-black/12 dark:border-b-initial dark:border-t-white/5 text-foreground border light:shadow-xs', 9 | destructive: 10 | 'border bg-rose-500 dark:bg-rose-600 text-white text-shadow-sm dark:border-black/30 border-black/5 light:border-b-black/15 dark:border-t-white/20 light:shadow-xs hover:bg-rose-600', 11 | } 12 | 13 | const buttonVariants = cva( 14 | 'inline-flex active:translate-y-[2px] active:scale-97 box-border cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[4px] text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 15 | { 16 | variants: { 17 | variant: { 18 | ...buttonStyles, 19 | default_gr: 20 | 'button-gr-secondary shadow-sm dark:bg-foreground/10 text-foreground dark:hover:bg-foreground/15 hover:bg-foreground/5', 21 | destructive_gr: 22 | 'button-gr-primary shadow-sm text-shadow-sm text-destructive-foreground dark:hover:bg-rose-500 hover:bg-rose-600', 23 | outline: 24 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 25 | secondary: 26 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 27 | ghost: 28 | 'hover:bg-foreground/10 rounded-sm aspect-square hover:text-foreground', 29 | link: 'text-primary underline-offset-4 hover:underline', 30 | }, 31 | size: { 32 | default: 'h-8 px-4 py-2', 33 | sm: 'h-9 rounded-md px-3', 34 | lg: 'h-11 rounded-md px-8', 35 | icon: 'h-10 w-10', 36 | }, 37 | }, 38 | defaultVariants: { 39 | variant: 'default', 40 | size: 'default', 41 | }, 42 | }, 43 | ) 44 | 45 | export type ButtonProps = React.ComponentProps & 46 | VariantProps 47 | 48 | const Button = React.forwardRef( 49 | ({ className, variant, size, ...props }, ref) => { 50 | return ( 51 | 57 | ) 58 | }, 59 | ) 60 | Button.displayName = 'Button' 61 | 62 | export { Button, buttonVariants } 63 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' 3 | import { Check, ChevronRight, Circle } from 'lucide-react' 4 | 5 | import { cn } from '@/utils' 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )) 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )) 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ) 179 | } 180 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | } 199 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/utils' 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, ...props }, ref) => { 7 | return ( 8 | 16 | ) 17 | }, 18 | ) 19 | Input.displayName = 'Input' 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' 3 | 4 | import { cn } from '@/utils' 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => { 10 | return ( 11 | 15 | 19 | {children} 20 | 21 | 22 | 23 | 24 | ) 25 | }) 26 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 27 | 28 | const ScrollBar = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 32 | 45 | 46 | 47 | )) 48 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 49 | 50 | export { ScrollArea, ScrollBar } 51 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@/context/theme.context' 2 | import { Toaster as Sonner } from 'sonner' 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 3 | 4 | import { cn } from '@/utils' 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 29 | -------------------------------------------------------------------------------- /src/context/edit-profile.tsx: -------------------------------------------------------------------------------- 1 | import { avatars } from '@/assets/avatars' 2 | import { Button } from '@/components/ui/button' 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuTrigger, 8 | } from '@/components/ui/dropdown-menu' 9 | import { Input } from '@/components/ui/input' 10 | import { api, getRandomElFromArray } from '@/lib/tauri' 11 | import { User } from '@/lib/tauri/api' 12 | import { AppState } from '@/state/appstate' 13 | import { ReactNode } from '@tanstack/react-router' 14 | import { ArrowRight, Check } from 'lucide-react' 15 | import { motion } from 'motion/react' 16 | import * as React from 'react' 17 | 18 | export const RAND_PREFIX_LIST = [ 19 | 'Cool', 20 | 'Awesome', 21 | 'Great', 22 | 'Fantastic', 23 | 'Super', 24 | 'Epic', 25 | 'Legendary', 26 | 'Mighty', 27 | 'Brave', 28 | 'Fearless', 29 | 'Bold', 30 | 'Daring', 31 | 'Courageous', 32 | 'Valiant', 33 | 'Gallant', 34 | ] 35 | export const RAND_SUFFIX_LIST = [ 36 | 'Warrior', 37 | 'Knight', 38 | 'Mage', 39 | 'Rogue', 40 | 'Paladin', 41 | 'Hunter', 42 | 'Bard', 43 | 'Druid', 44 | 'Sorcerer', 45 | 'Assassin', 46 | 'Guardian', 47 | 'Champion', 48 | 'Defender', 49 | 'Avenger', 50 | 'Savior', 51 | ] 52 | 53 | function getRandomName() { 54 | const prefix = getRandomElFromArray(RAND_PREFIX_LIST) 55 | const suffix = getRandomElFromArray(RAND_SUFFIX_LIST) 56 | return `${prefix} ${suffix}` 57 | } 58 | 59 | const defaultUser = { 60 | name: getRandomName(), 61 | avatar: Math.floor(Math.random() * avatars.length), 62 | } 63 | 64 | export function EditProfile({ 65 | buttonLabel, 66 | user: prefillUser, 67 | }: { 68 | buttonLabel?: ReactNode 69 | user?: User 70 | }) { 71 | const [user, setUser] = React.useState(prefillUser || defaultUser) 72 | const [saveSuccess, setSaveSuccess] = React.useState(false) 73 | 74 | function handleNameChange(e: React.ChangeEvent) { 75 | const value = e.target.value 76 | setUser((prev) => ({ ...prev, name: value })) 77 | } 78 | 79 | async function onSubmit(e: React.FormEvent) { 80 | e.preventDefault() 81 | const res = await api.updateUser(user) 82 | if (res.isOk()) { 83 | setSaveSuccess(true) 84 | setTimeout(() => setSaveSuccess(false), 2000) 85 | AppState.set({ user }) 86 | } 87 | } 88 | 89 | return ( 90 |
91 | 92 | 93 | 97 | 98 | 99 | {avatars.map((avatar, index) => ( 100 | setUser((prev) => ({ ...prev, avatar: index }))} 104 | > 105 | 106 | 107 | ))} 108 | 109 | 110 |
111 | 112 |
113 | 114 | 133 | 134 |
135 |
136 |
137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /src/context/theme.context.tsx: -------------------------------------------------------------------------------- 1 | import { api } from '@/lib/tauri' 2 | import { createContext, useContext, useEffect, useState } from 'react' 3 | 4 | export type Theme = 'dark' | 'light' | 'system' 5 | export type ResolvedTheme = 'dark' | 'light' 6 | 7 | type ThemeProviderProps = { 8 | children: React.ReactNode 9 | defaultTheme?: Theme 10 | storageKey?: string 11 | } 12 | 13 | type ThemeProviderState = { 14 | theme: Theme 15 | resolvedTheme: ResolvedTheme 16 | setTheme: (theme: Theme) => void 17 | } 18 | 19 | const THEME_KEY = 'vite-ui-theme' 20 | const initialState: ThemeProviderState = { 21 | theme: 'system', 22 | resolvedTheme: 'light', 23 | setTheme: () => null, 24 | } 25 | 26 | const ThemeContext = createContext(initialState) 27 | 28 | const DARK_MODE_MEDIA = window.matchMedia('(prefers-color-scheme: dark)') 29 | const ROOT = window.document.documentElement 30 | 31 | function changeRootTheme(theme: ResolvedTheme) { 32 | api.setTheme(theme) 33 | ROOT.classList.remove('light', 'dark') 34 | ROOT.classList.add(theme) 35 | } 36 | 37 | export function ThemeProvider({ 38 | children, 39 | defaultTheme = 'system', 40 | storageKey = 'vite-ui-theme', 41 | ...props 42 | }: ThemeProviderProps) { 43 | function getThemeFromStorage(): Theme { 44 | return (localStorage.getItem(THEME_KEY) as Theme) || defaultTheme 45 | } 46 | 47 | function getResolvedTheme(): ResolvedTheme { 48 | const storedTheme = getThemeFromStorage() 49 | if (storedTheme == 'light' || storedTheme == 'dark') return storedTheme 50 | return DARK_MODE_MEDIA.matches ? 'dark' : 'light' 51 | } 52 | 53 | const [theme, setTheme] = useState(getThemeFromStorage) 54 | const resolvedTheme = getResolvedTheme() 55 | 56 | useEffect(() => { 57 | if (theme != 'system') { 58 | changeRootTheme(theme) 59 | return 60 | } 61 | 62 | const systemTheme = DARK_MODE_MEDIA.matches ? 'dark' : 'light' 63 | changeRootTheme(systemTheme) 64 | 65 | const onThemeChanged = (event: MediaQueryListEvent) => { 66 | const systemTheme = event.matches ? 'dark' : 'light' 67 | changeRootTheme(systemTheme) 68 | } 69 | 70 | DARK_MODE_MEDIA.addEventListener('change', onThemeChanged) 71 | return () => DARK_MODE_MEDIA.removeEventListener('change', onThemeChanged) 72 | }, [theme]) 73 | 74 | useEffect(() => { 75 | if (theme != 'system') { 76 | return 77 | } 78 | }, [theme]) 79 | 80 | const value = { 81 | theme, 82 | resolvedTheme, 83 | setTheme: (theme: Theme) => { 84 | localStorage.setItem(storageKey, theme) 85 | setTheme(theme) 86 | }, 87 | } 88 | 89 | return ( 90 | 91 | {children} 92 | 93 | ) 94 | } 95 | 96 | export const useTheme = () => { 97 | const context = useContext(ThemeContext) 98 | 99 | if (context === undefined) 100 | throw new Error('useTheme must be used within a ThemeProvider') 101 | 102 | return context 103 | } 104 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | @custom-variant light (&:where(:not(.dark), :not(.dark) *)); 6 | 7 | :root { 8 | --background: 0 0% 100%; 9 | --background-gr-a: 0 0% 90%; 10 | --background-gr-b: 0 0% 86%; 11 | --foreground: 240 10% 3.9%; 12 | 13 | --card: 0 0% 100%; 14 | --card-foreground: 240 10% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 240 10% 3.9%; 17 | 18 | --primary: 347 77% 50%; 19 | --primary-gr-a: 347 94% 57%; 20 | --primary-gr-b: 347 78% 46%; 21 | --primary-border-gr-b: 347 100% 73%; 22 | --primary-border-gr-a: 347 66% 39%; 23 | --primary-foreground: 355.7 100% 97.3%; 24 | 25 | --secondary: 240 4.8% 95.9%; 26 | --secondary-gr-a: 0 0% 90%; 27 | --secondary-gr-b: 0 0% 100%; 28 | --secondary-border-gr-a: 0 0% 80%; 29 | --secondary-border-gr-b: 0 0% 90%; 30 | --secondary-foreground: 240 5.9% 10%; 31 | 32 | --muted: 240 4.8% 95.9%; 33 | --muted-foreground: 240 3.8% 46.1%; 34 | --accent: 240 4.8% 95.9%; 35 | --accent-foreground: 240 5.9% 10%; 36 | --destructive: 0 84.2% 60.2%; 37 | --destructive-foreground: 0 0% 98%; 38 | --border: 240 5.9% 90%; 39 | --input: 240 5.9% 90%; 40 | --ring: 346.8 77.2% 49.8%; 41 | --radius: 0.75rem; 42 | --chart-1: 12 76% 61%; 43 | --chart-2: 173 58% 39%; 44 | --chart-3: 197 37% 24%; 45 | --chart-4: 43 74% 66%; 46 | --chart-5: 27 87% 67%; 47 | --shadow-rim: inset 0 -1px 0 0 #00000015; 48 | } 49 | 50 | .dark { 51 | --background: 20 14.3% 4.1%; 52 | --background-gr-a: 0 0% 15%; 53 | --background-gr-b: 0 0% 9%; 54 | --foreground: 240 10% 3.9%; 55 | --foreground: 0 0% 95%; 56 | --card: 24 9.8% 10%; 57 | --card-foreground: 0 0% 95%; 58 | --popover: 0 0% 9%; 59 | --popover-foreground: 0 0% 95%; 60 | 61 | --primary: 346.8 77.2% 49.8%; 62 | --primary-gr-a: 347 94% 57%; 63 | --primary-gr-b: 347 78% 46%; 64 | --primary-border-gr-a: 347 100% 73%; 65 | --primary-border-gr-b: 347 66% 39%; 66 | --primary-foreground: 355.7 100% 97.3%; 67 | 68 | --secondary: 240 3.7% 15.9%; 69 | --secondary-gr-a: 0 0% 21%; 70 | --secondary-gr-b: 0 0% 15%; 71 | --secondary-border-gr-a: 0 0% 35%; 72 | --secondary-border-gr-b: 0 0% 17%; 73 | --secondary-foreground: 0 0% 98%; 74 | 75 | --muted: 0 0% 15%; 76 | --muted-foreground: 240 5% 64.9%; 77 | --accent: 12 6.5% 15.1%; 78 | --accent-foreground: 0 0% 98%; 79 | --destructive: 0 62.8% 30.6%; 80 | --destructive-foreground: 0 85.7% 97.3%; 81 | --border: 240 3.7% 15.9%; 82 | --input: 240 3.7% 15.9%; 83 | --ring: 346.8 77.2% 49.8%; 84 | --chart-1: 220 70% 50%; 85 | --chart-2: 160 60% 45%; 86 | --chart-3: 30 80% 55%; 87 | --chart-4: 280 65% 60%; 88 | --chart-5: 340 75% 55%; 89 | --shadow-rim: inset 0 1px 0 0 #ffffff10; 90 | } 91 | 92 | @theme inline { 93 | --color-background: hsl(var(--background)); 94 | --color-foreground: hsl(var(--foreground)); 95 | --color-card: hsl(var(--card)); 96 | --color-card-foreground: hsl(var(--card-foreground)); 97 | --color-popover: hsl(var(--popover)); 98 | --color-popover-foreground: hsl(var(--popover-foreground)); 99 | --color-primary: hsl(var(--primary)); 100 | --color-primary-foreground: hsl(var(--primary-foreground)); 101 | --color-secondary: hsl(var(--secondary)); 102 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 103 | --color-muted: hsl(var(--muted)); 104 | --color-muted-foreground: hsl(var(--muted-foreground)); 105 | --color-accent: hsl(var(--accent)); 106 | --color-accent-foreground: hsl(var(--accent-foreground)); 107 | --color-destructive: hsl(var(--destructive)); 108 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 109 | --color-border: hsl(var(--border)); 110 | --color-input: hsl(var(--input)); 111 | --color-ring: hsl(var(--ring)); 112 | --color-chart-1: hsl(var(--chart-1)); 113 | --color-chart-2: hsl(var(--chart-2)); 114 | --color-chart-3: hsl(var(--chart-3)); 115 | --color-chart-4: hsl(var(--chart-4)); 116 | --color-chart-5: hsl(var(--chart-5)); 117 | --color-sidebar: hsl(var(--sidebar)); 118 | --color-sidebar-foreground: hsl(var(--sidebar-foreground)); 119 | --color-sidebar-primary: hsl(var(--sidebar-primary)); 120 | --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); 121 | --color-sidebar-accent: hsl(var(--sidebar-accent)); 122 | --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); 123 | --color-sidebar-border: hsl(var(--sidebar-border)); 124 | --color-sidebar-ring: hsl(var(--sidebar-ring)); 125 | 126 | --radius-sm: calc(var(--radius) - 4px); 127 | --radius-md: calc(var(--radius) - 2px); 128 | --radius-lg: var(--radius); 129 | --radius-xl: calc(var(--radius) + 4px); 130 | 131 | --font-sans: 132 | Poppins, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 133 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 134 | 135 | --font-mono: 136 | 'Jetbrains Mono', ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', 137 | 'Courier New', monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 138 | 'Segoe UI Symbol', 'Noto Color Emoji'; 139 | 140 | --color-background-gr-a: hsl(var(--background-gr-a)); 141 | --color-background-gr-b: hsl(var(--background-gr-b)); 142 | --color-secondary-gr-a: hsl(var(--secondary-gr-a)); 143 | --color-secondary-gr-b: hsl(var(--secondary-gr-b)); 144 | --color-primary-gr-a: hsl(var(--primary-gr-a)); 145 | --color-primary-gr-b: hsl(var(--primary-gr-b)); 146 | 147 | --color-primary-border-gr-a: hsl(var(--primary-border-gr-a)); 148 | --color-primary-border-gr-b: hsl(var(--primary-border-gr-b)); 149 | --color-secondary-border-gr-a: hsl(var(--secondary-border-gr-a)); 150 | --color-secondary-border-gr-b: hsl(var(--secondary-border-gr-b)); 151 | 152 | --shadow-rim: var(--shadow-rim); 153 | 154 | --animate-spin-ease: spin 1s ease-in-out infinite; 155 | } 156 | 157 | @layer base { 158 | * { 159 | @apply border-border; 160 | } 161 | 162 | body, 163 | html { 164 | @apply text-foreground size-full h-full scroll-smooth bg-transparent; 165 | } 166 | 167 | body { 168 | @apply text-foreground bg-gradient-to-br from-[#efefef] to-[#dddddd] dark:bg-[#202020]; 169 | 170 | @media (prefers-color-scheme: dark) { 171 | background: #202020; 172 | } 173 | 174 | &:where(.dark *) { 175 | background: #202020; 176 | } 177 | 178 | &.mica { 179 | background: transparent !important; 180 | } 181 | } 182 | 183 | #root { 184 | @apply flex h-screen w-screen flex-col overflow-hidden; 185 | } 186 | 187 | ::-webkit-scrollbar { 188 | @apply w-0; 189 | } 190 | .non-container { 191 | @apply -ml-3 w-[calc(100%+0.75rem+0.75rem)]; 192 | } 193 | 194 | .button-gr-primary { 195 | @apply from-primary-gr-a to-primary-gr-b relative m-px rounded-[3px] bg-gradient-to-br; 196 | } 197 | 198 | .button-gr-primary::after { 199 | @apply from-primary-border-gr-a to-primary-border-gr-b absolute -inset-px z-[-1] rounded-[4px] bg-gradient-to-br content-[""]; 200 | } 201 | 202 | .button-gr-secondary { 203 | @apply from-secondary-gr-a to-secondary-gr-b relative m-px rounded-[3px] bg-gradient-to-br; 204 | } 205 | 206 | .button-gr-secondary::after { 207 | @apply from-secondary-border-gr-a to-secondary-border-gr-b absolute -inset-px z-[-1] rounded-[4px] bg-gradient-to-br content-[""]; 208 | } 209 | 210 | .surface-gr-secondary { 211 | @apply from-secondary-gr-a to-secondary-gr-b relative m-px rounded-[3px] bg-gradient-to-br; 212 | } 213 | 214 | .surface-gr-secondary::after { 215 | @apply from-secondary-border-gr-a to-secondary-border-gr-b absolute -inset-px z-[-1] rounded-[4px] bg-gradient-to-br content-[""]; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/lib/post-hog.ts: -------------------------------------------------------------------------------- 1 | // Validate PostHog env variables 2 | const posthogKeys = { 3 | apiKey: import.meta.env.VITE_POSTHOG_KEY, 4 | host: import.meta.env.VITE_POSTHOG_HOST, 5 | } 6 | 7 | if (!posthogKeys.apiKey || !posthogKeys.host) { 8 | throw new Error('PostHog API key or host is not set') 9 | } 10 | 11 | export { posthogKeys } 12 | -------------------------------------------------------------------------------- /src/lib/tanstack-router.ts: -------------------------------------------------------------------------------- 1 | import { routeTree } from '@/routeTree.gen' 2 | import { createRouter } from '@tanstack/react-router' 3 | 4 | export type Router = typeof router 5 | 6 | declare module '@tanstack/react-router' { 7 | interface Register { 8 | router: Router 9 | } 10 | } 11 | export const router = createRouter({ routeTree }) 12 | -------------------------------------------------------------------------------- /src/lib/tauri/api.ts: -------------------------------------------------------------------------------- 1 | import { emit } from '@tauri-apps/api/event' 2 | import { invoke } from './utils' 3 | import { Theme } from '@/context/theme.context' 4 | import { CANCEL_DOWNLOAD } from './events' 5 | import { DownloadFile, ValidatedFile } from './types' 6 | /** 7 | * Clean up the database directory. 8 | */ 9 | export function validateFiles(paths: string[]) { 10 | return invoke('validate_files', { paths }) 11 | } 12 | 13 | /** 14 | * Clean up the database directory. 15 | */ 16 | export function cleanUp() { 17 | return invoke('clean_up') 18 | } 19 | 20 | /** 21 | * Add a file to the list. 22 | * @param path - The path of the file to add. 23 | */ 24 | export function addFile(path: string) { 25 | return invoke('add_file', { path }) 26 | } 27 | 28 | /** 29 | * 30 | * @param paths - The paths of the files to add. 31 | * @returns - Array of Results 32 | */ 33 | export function addFiles(paths: string[]) { 34 | return paths.map((path) => addFile(path)) 35 | } 36 | 37 | /** 38 | * Remove a file. 39 | * @param path - The path of the file to remove. 40 | */ 41 | export function removeFile(path: string) { 42 | return invoke('remove_file', { path }) 43 | } 44 | 45 | /** 46 | * Remove all files from the list. 47 | * @param path - The path to remove all files from. 48 | */ 49 | export function removeAllFiles() { 50 | return invoke('remove_all_files', {}) 51 | } 52 | 53 | /** 54 | * Generate a doc ticket for a file. 55 | */ 56 | export function generateTicket() { 57 | return invoke('generate_ticket') 58 | } 59 | 60 | /** 61 | * Download header file using a doc ticket. 62 | * @param ticket - The doc ticket to use for downloading. 63 | */ 64 | export function downloadHeader(ticket: string) { 65 | return invoke('download_header', { ticket }) 66 | } 67 | 68 | /** 69 | * Download file. 70 | * @param file - The file to download. 71 | */ 72 | export function downloadFile(file: DownloadFile) { 73 | return invoke('download_header', { file }) 74 | } 75 | 76 | export function getFileIcon(path: string) { 77 | return invoke('get_file_icon', { path }) 78 | } 79 | 80 | /** 81 | * Abort a download using a doc ticket. 82 | * @param ticket - The doc ticket to use for aborting. 83 | */ 84 | export function abortDownload(name: string) { 85 | return emit(CANCEL_DOWNLOAD, name) 86 | } 87 | 88 | /** 89 | * Set Theme 90 | */ 91 | export function setTheme(theme: Theme) { 92 | return invoke('set_theme', { theme }) 93 | } 94 | 95 | export type User = { 96 | name: string 97 | avatar: number 98 | } 99 | 100 | /** 101 | * Get User data 102 | */ 103 | export function getUser() { 104 | return invoke('get_user') 105 | } 106 | 107 | /** 108 | * Set User Avatar 109 | */ 110 | export function updateUser(user: User) { 111 | return invoke('update_user', { user }) 112 | } 113 | 114 | export function isOnboarded() { 115 | return invoke('is_onboarded') 116 | } 117 | 118 | export function appLoaded() { 119 | return invoke('app_loaded') 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/tauri/events.ts: -------------------------------------------------------------------------------- 1 | // DOWNLOAD 2 | // 3 | export const DOWNLOAD_FILE_ADDED = 'DOWNLOAD_FILE_ADDED' 4 | export const DOWNLOAD_FILE_PROGRESS = 'DOWNLOAD_FILE_PROGRESS' 5 | export const DOWNLOAD_FILE_COMPLETED = 'DOWNLOAD_FILE_COMPLETED' 6 | export const DOWNLOAD_ALL_COMPLETE = 'DOWNLOAD_ALL_COMPLETE' 7 | export const DOWNLOAD_FILE_ERROR = 'DOWNLOAD_FILE_ERROR' 8 | export const DOWNLOAD_FILE_ABORTED = 'DOWNLOAD_FILE_ABORTED' 9 | export const CANCEL_DOWNLOAD = 'CANCEL_DOWNLOAD' 10 | 11 | export type DownloadFileAdded = { 12 | name: string 13 | icon: string 14 | size: number 15 | } 16 | export type DownloadFileProgress = { 17 | name: string 18 | progress: number 19 | speed: number // bytes per microsecond 20 | } 21 | export type DownloadFileCompleted = { name: string; path: string } 22 | 23 | export type DownloadFileAborted = { 24 | name: string 25 | reason: string 26 | } 27 | 28 | export type DownloadFileError = { 29 | name: string 30 | error: string 31 | } 32 | 33 | // UPLOAD 34 | export const UPLOAD_FILE_ADDED = 'UPLOAD_FILE_ADDED' 35 | export const UPLOAD_FILE_PROGRESS = 'UPLOAD_FILE_PROGRESS' 36 | export const UPLOAD_FILE_COMPLETED = 'UPLOAD_FILE_COMPLETED' 37 | export const UPLOAD_FILE_REMOVED = 'UPLOAD_FILE_REMOVED' 38 | export const UPLOAD_FILE_ERROR = 'UPLOAD_FILE_ERROR' 39 | 40 | export type UploadFileAdded = { 41 | name: string 42 | icon: string 43 | path: string 44 | size: number 45 | } 46 | export type UploadFileProgress = { 47 | path: string 48 | progress: number 49 | } 50 | export type UploadFileCompleted = { name: string } 51 | export type UploadFileRemoved = { name: string } 52 | export type UploadFileError = { 53 | name: string 54 | error: string 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/tauri/index.ts: -------------------------------------------------------------------------------- 1 | export * as api from './api.ts' 2 | export * from './types.ts' 3 | export * as events from './events.ts' 4 | export * from './utils.ts' 5 | -------------------------------------------------------------------------------- /src/lib/tauri/types.ts: -------------------------------------------------------------------------------- 1 | export type DownloadFile = { 2 | name: string 3 | icon: string 4 | size: number 5 | hash: string 6 | } 7 | 8 | export type ValidatedFile = { 9 | name: string 10 | icon: string 11 | path: string 12 | size: number 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/tauri/utils.ts: -------------------------------------------------------------------------------- 1 | import { invoke as tauri__invoke } from '@tauri-apps/api/core' 2 | import { 3 | EventCallback, 4 | EventName, 5 | Options, 6 | listen as tauri__listen, 7 | } from '@tauri-apps/api/event' 8 | import { writeText } from '@tauri-apps/plugin-clipboard-manager' 9 | import * as log from '@tauri-apps/plugin-log' 10 | import { revealItemInDir as tauri__revealItemInDir } from '@tauri-apps/plugin-opener' 11 | import { err, ok, Result } from 'neverthrow' 12 | import { toast } from 'sonner' 13 | 14 | export async function revealItemInDir( 15 | path: string, 16 | ): Promise> { 17 | try { 18 | await tauri__revealItemInDir(path) 19 | return ok() 20 | } catch (e) { 21 | return err(e as string) 22 | } 23 | } 24 | 25 | export async function copyText(text: string): Promise> { 26 | try { 27 | await writeText(text) 28 | return ok() 29 | } catch (e) { 30 | return err(e as string) 31 | } 32 | } 33 | 34 | export async function invoke( 35 | command: string, 36 | args?: any, 37 | ): Promise> { 38 | try { 39 | const res: T = await tauri__invoke(command, args) 40 | return ok(res) 41 | } catch (error) { 42 | let e = error as string 43 | console.log(e) 44 | toast.error(e) 45 | log.error(e) 46 | return err(e) 47 | } 48 | } 49 | 50 | export async function listen( 51 | event: EventName, 52 | callback: EventCallback, 53 | options?: Options & { signal?: AbortSignal }, 54 | ) { 55 | const { signal, ...opts } = options ?? {} 56 | const unsub = tauri__listen(event, callback, opts) 57 | 58 | signal?.addEventListener('abort', () => unsub.then((f) => f())) 59 | return unsub 60 | } 61 | 62 | export function listeners( 63 | record: Partial>>, 64 | ): () => void { 65 | const controller = new AbortController() 66 | 67 | for (const [event, callback] of Object.entries(record)) { 68 | if (!callback) continue 69 | listen(event, callback, { signal: controller.signal }) 70 | } 71 | return () => { 72 | controller.abort() 73 | } 74 | } 75 | 76 | export function getRandomElFromArray(arr: T[]): T { 77 | return arr[Math.floor(Math.random() * arr.length)] 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/zustand.ts: -------------------------------------------------------------------------------- 1 | import { StoreApi, UseBoundStore } from 'zustand' 2 | import { useShallow } from 'zustand/react/shallow' 3 | 4 | export const createSelector = 5 | >( 6 | store: UseBoundStore>, 7 | ) => 8 | (...keys: TKey[]) => 9 | store(useShallow(getStoreMapByKeys(keys))) 10 | 11 | const getStoreMapByKeys = 12 | , TKey extends keyof TStore>( 13 | keys: TKey[], 14 | ) => 15 | (state: TStore) => { 16 | if (keys.length === 0) return state 17 | const map = {} as { [K in TKey]: TStore[K] } 18 | for (const key of keys) { 19 | map[key] = state[key] 20 | } 21 | return map 22 | } 23 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from '@tanstack/react-router' 2 | import { createRoot } from 'react-dom/client' 3 | import { Toaster } from './components/ui/sonner' 4 | import { TooltipProvider } from './components/ui/tooltip' 5 | import { ThemeProvider } from './context/theme.context' 6 | import { isWindows11 } from 'tauri-plugin-windows-version-api' 7 | import { PostHogProvider } from 'posthog-js/react' 8 | import { disableBrowserDefaultBehaviours } from '@/utils' 9 | import { posthogKeys } from './lib/post-hog' 10 | import { router } from './lib/tanstack-router' 11 | import React from 'react' 12 | 13 | // MICA SETUP: We use Mica effect for windows 11 so we need to set the background color to transparent 14 | isWindows11().then((res) => res && document.body.classList.add('mica')) 15 | 16 | // This disables right click context menu and reloading the app 17 | disableBrowserDefaultBehaviours() 18 | 19 | const container = document.getElementById('root') 20 | createRoot(container!).render( 21 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | , 34 | ) 35 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from './routes/__root' 14 | import { Route as PagesImport } from './routes/_pages' 15 | import { Route as PagesIndexImport } from './routes/_pages/index' 16 | import { Route as PagesSendImport } from './routes/_pages/send' 17 | import { Route as PagesReceiveImport } from './routes/_pages/receive' 18 | import { Route as PagesOnboardImport } from './routes/_pages/onboard' 19 | import { Route as PagesEditProfileImport } from './routes/_pages/edit-profile' 20 | 21 | // Create/Update Routes 22 | 23 | const PagesRoute = PagesImport.update({ 24 | id: '/_pages', 25 | getParentRoute: () => rootRoute, 26 | } as any) 27 | 28 | const PagesIndexRoute = PagesIndexImport.update({ 29 | id: '/', 30 | path: '/', 31 | getParentRoute: () => PagesRoute, 32 | } as any) 33 | 34 | const PagesSendRoute = PagesSendImport.update({ 35 | id: '/send', 36 | path: '/send', 37 | getParentRoute: () => PagesRoute, 38 | } as any) 39 | 40 | const PagesReceiveRoute = PagesReceiveImport.update({ 41 | id: '/receive', 42 | path: '/receive', 43 | getParentRoute: () => PagesRoute, 44 | } as any) 45 | 46 | const PagesOnboardRoute = PagesOnboardImport.update({ 47 | id: '/onboard', 48 | path: '/onboard', 49 | getParentRoute: () => PagesRoute, 50 | } as any) 51 | 52 | const PagesEditProfileRoute = PagesEditProfileImport.update({ 53 | id: '/edit-profile', 54 | path: '/edit-profile', 55 | getParentRoute: () => PagesRoute, 56 | } as any) 57 | 58 | // Populate the FileRoutesByPath interface 59 | 60 | declare module '@tanstack/react-router' { 61 | interface FileRoutesByPath { 62 | '/_pages': { 63 | id: '/_pages' 64 | path: '' 65 | fullPath: '' 66 | preLoaderRoute: typeof PagesImport 67 | parentRoute: typeof rootRoute 68 | } 69 | '/_pages/edit-profile': { 70 | id: '/_pages/edit-profile' 71 | path: '/edit-profile' 72 | fullPath: '/edit-profile' 73 | preLoaderRoute: typeof PagesEditProfileImport 74 | parentRoute: typeof PagesImport 75 | } 76 | '/_pages/onboard': { 77 | id: '/_pages/onboard' 78 | path: '/onboard' 79 | fullPath: '/onboard' 80 | preLoaderRoute: typeof PagesOnboardImport 81 | parentRoute: typeof PagesImport 82 | } 83 | '/_pages/receive': { 84 | id: '/_pages/receive' 85 | path: '/receive' 86 | fullPath: '/receive' 87 | preLoaderRoute: typeof PagesReceiveImport 88 | parentRoute: typeof PagesImport 89 | } 90 | '/_pages/send': { 91 | id: '/_pages/send' 92 | path: '/send' 93 | fullPath: '/send' 94 | preLoaderRoute: typeof PagesSendImport 95 | parentRoute: typeof PagesImport 96 | } 97 | '/_pages/': { 98 | id: '/_pages/' 99 | path: '/' 100 | fullPath: '/' 101 | preLoaderRoute: typeof PagesIndexImport 102 | parentRoute: typeof PagesImport 103 | } 104 | } 105 | } 106 | 107 | // Create and export the route tree 108 | 109 | interface PagesRouteChildren { 110 | PagesEditProfileRoute: typeof PagesEditProfileRoute 111 | PagesOnboardRoute: typeof PagesOnboardRoute 112 | PagesReceiveRoute: typeof PagesReceiveRoute 113 | PagesSendRoute: typeof PagesSendRoute 114 | PagesIndexRoute: typeof PagesIndexRoute 115 | } 116 | 117 | const PagesRouteChildren: PagesRouteChildren = { 118 | PagesEditProfileRoute: PagesEditProfileRoute, 119 | PagesOnboardRoute: PagesOnboardRoute, 120 | PagesReceiveRoute: PagesReceiveRoute, 121 | PagesSendRoute: PagesSendRoute, 122 | PagesIndexRoute: PagesIndexRoute, 123 | } 124 | 125 | const PagesRouteWithChildren = PagesRoute._addFileChildren(PagesRouteChildren) 126 | 127 | export interface FileRoutesByFullPath { 128 | '': typeof PagesRouteWithChildren 129 | '/edit-profile': typeof PagesEditProfileRoute 130 | '/onboard': typeof PagesOnboardRoute 131 | '/receive': typeof PagesReceiveRoute 132 | '/send': typeof PagesSendRoute 133 | '/': typeof PagesIndexRoute 134 | } 135 | 136 | export interface FileRoutesByTo { 137 | '/edit-profile': typeof PagesEditProfileRoute 138 | '/onboard': typeof PagesOnboardRoute 139 | '/receive': typeof PagesReceiveRoute 140 | '/send': typeof PagesSendRoute 141 | '/': typeof PagesIndexRoute 142 | } 143 | 144 | export interface FileRoutesById { 145 | __root__: typeof rootRoute 146 | '/_pages': typeof PagesRouteWithChildren 147 | '/_pages/edit-profile': typeof PagesEditProfileRoute 148 | '/_pages/onboard': typeof PagesOnboardRoute 149 | '/_pages/receive': typeof PagesReceiveRoute 150 | '/_pages/send': typeof PagesSendRoute 151 | '/_pages/': typeof PagesIndexRoute 152 | } 153 | 154 | export interface FileRouteTypes { 155 | fileRoutesByFullPath: FileRoutesByFullPath 156 | fullPaths: '' | '/edit-profile' | '/onboard' | '/receive' | '/send' | '/' 157 | fileRoutesByTo: FileRoutesByTo 158 | to: '/edit-profile' | '/onboard' | '/receive' | '/send' | '/' 159 | id: 160 | | '__root__' 161 | | '/_pages' 162 | | '/_pages/edit-profile' 163 | | '/_pages/onboard' 164 | | '/_pages/receive' 165 | | '/_pages/send' 166 | | '/_pages/' 167 | fileRoutesById: FileRoutesById 168 | } 169 | 170 | export interface RootRouteChildren { 171 | PagesRoute: typeof PagesRouteWithChildren 172 | } 173 | 174 | const rootRouteChildren: RootRouteChildren = { 175 | PagesRoute: PagesRouteWithChildren, 176 | } 177 | 178 | export const routeTree = rootRoute 179 | ._addFileChildren(rootRouteChildren) 180 | ._addFileTypes() 181 | 182 | /* ROUTE_MANIFEST_START 183 | { 184 | "routes": { 185 | "__root__": { 186 | "filePath": "__root.tsx", 187 | "children": [ 188 | "/_pages" 189 | ] 190 | }, 191 | "/_pages": { 192 | "filePath": "_pages.tsx", 193 | "children": [ 194 | "/_pages/edit-profile", 195 | "/_pages/onboard", 196 | "/_pages/receive", 197 | "/_pages/send", 198 | "/_pages/" 199 | ] 200 | }, 201 | "/_pages/edit-profile": { 202 | "filePath": "_pages/edit-profile.tsx", 203 | "parent": "/_pages" 204 | }, 205 | "/_pages/onboard": { 206 | "filePath": "_pages/onboard.tsx", 207 | "parent": "/_pages" 208 | }, 209 | "/_pages/receive": { 210 | "filePath": "_pages/receive.tsx", 211 | "parent": "/_pages" 212 | }, 213 | "/_pages/send": { 214 | "filePath": "_pages/send.tsx", 215 | "parent": "/_pages" 216 | }, 217 | "/_pages/": { 218 | "filePath": "_pages/index.tsx", 219 | "parent": "/_pages" 220 | } 221 | } 222 | } 223 | ROUTE_MANIFEST_END */ 224 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Outlet, createRootRoute } from '@tanstack/react-router' 3 | import { SendPageListeners } from './_pages/send' 4 | import { ReceivePageListeners } from './_pages/receive' 5 | import { AppState } from '@/state/appstate' 6 | import { api, listen } from '@/lib/tauri' 7 | import { Loader2 } from 'lucide-react' 8 | import { TitleBar } from '@/components/titlebar' 9 | 10 | export const Route = createRootRoute({ 11 | component: RootComponent, 12 | }) 13 | 14 | async function loadUser() { 15 | const user = await api.getUser() 16 | if (user.isOk()) AppState.set({ user: user.value }) 17 | } 18 | 19 | function RootComponent() { 20 | const [loaded, setLoaded] = React.useState(false) 21 | 22 | async function onStateLoaded() { 23 | await loadUser() 24 | setLoaded(true) 25 | } 26 | 27 | async function isLoaded() { 28 | const loadedRes = await api.appLoaded() 29 | const res = loadedRes.match( 30 | (v) => v, 31 | () => false, 32 | ) 33 | await loadUser() 34 | setLoaded(res) 35 | } 36 | 37 | React.useEffect(() => { 38 | if (loaded) return 39 | isLoaded() 40 | 41 | const con = new AbortController() 42 | listen('APP_LOADED', onStateLoaded, { signal: con.signal }) 43 | 44 | return () => void con.abort() 45 | }, [loaded]) 46 | 47 | let content = ( 48 | <> 49 | 50 | 51 |
52 | 53 |
54 | 55 | ) 56 | 57 | if (!loaded) { 58 | content = ( 59 |
60 | 61 | Loading 62 |
63 | ) 64 | } 65 | 66 | return ( 67 | <> 68 | 69 | {content} 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/routes/_pages.tsx: -------------------------------------------------------------------------------- 1 | import { AppState } from '@/state/appstate' 2 | import { 3 | createFileRoute, 4 | LinkProps, 5 | Outlet, 6 | redirect, 7 | useLocation, 8 | } from '@tanstack/react-router' 9 | import { AnimatePresence, motion } from 'motion/react' 10 | 11 | export const Route = createFileRoute('/_pages')({ 12 | component: RouteComponent, 13 | beforeLoad: async (ctx) => { 14 | const user = AppState.get().user 15 | const isOnboardPath = ctx.location.pathname 16 | .toLowerCase() 17 | .includes('onboard') 18 | 19 | if (isOnboardPath) { 20 | if (user) throw redirect({ to: '/' }) 21 | return 22 | } 23 | 24 | if (!user) throw redirect({ to: '/onboard' }) 25 | }, 26 | }) 27 | 28 | type Title = { 29 | title: string 30 | description: string 31 | } 32 | type ValidRoutes = LinkProps['to'] & {} 33 | 34 | const routeTitleMap: Partial> = { 35 | '/': { 36 | title: 'SendIt', 37 | description: 'Send and receive files securely and easily.', 38 | }, 39 | '/send': { 40 | title: 'Send Files', 41 | description: 'Add files and generate a ticket to share them with others.', 42 | }, 43 | '/receive': { 44 | title: 'Receive Files', 45 | description: 'Receive files using the ticket.', 46 | }, 47 | '/onboard': { 48 | title: 'Welcome to SendIt!', 49 | description: "Let's get you started with SendIt.", 50 | }, 51 | '/edit-profile': { 52 | title: 'Edit Profile', 53 | description: 'Edit your profile information.', 54 | }, 55 | } 56 | 57 | function RouteComponent() { 58 | const location = useLocation() 59 | 60 | const { title, description } = 61 | routeTitleMap[location.pathname as ValidRoutes]! 62 | 63 | const animate = { opacity: 1, y: 0, filter: 'blur(0px)' } 64 | const initial = { opacity: 0, y: -20, filter: 'blur(4px)' } 65 | 66 | return ( 67 |
68 | 69 | 75 | {title} 76 | 77 | 84 | {description} 85 | 86 | 93 | 94 | 95 | 96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/routes/_pages/-components/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { bytesToString } from '@/utils' 2 | import { motion, MotionValue, useTransform } from 'motion/react' 3 | import { useEffect, useState } from 'react' 4 | 5 | type ProgressBarProps = { 6 | /** Progress percentage */ 7 | progress: MotionValue 8 | showPercentage?: boolean 9 | speed?: number 10 | } 11 | 12 | export function ProgressBar({ 13 | progress, 14 | showPercentage, 15 | speed, 16 | }: ProgressBarProps) { 17 | const progressPercentageStr = useTransform(progress, (x) => `${x}%`) 18 | const [progressPercentage, setProgressPercentage] = useState(0) 19 | 20 | useEffect(() => { 21 | const unsub = progress.on('change', (newP) => 22 | setProgressPercentage(Math.round(newP)), 23 | ) 24 | return unsub 25 | }, []) 26 | 27 | return ( 28 |
29 |
30 | 37 |
38 | {showPercentage && ( 39 |

40 | {progressPercentage}% 41 |

42 | )} 43 | 44 | {!!speed && ( 45 |

46 | {bytesToString(speed * 1000_000)}/s 47 |

48 | )} 49 |
50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/routes/_pages/-components/queue-container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ScrollArea } from '@/components/ui/scroll-area' 3 | import { AnimatePresence } from 'motion/react' 4 | 5 | type QueueContainerProps = Omit< 6 | React.ComponentProps, 7 | 'className' 8 | > 9 | 10 | export const QueueContainer = React.forwardRef< 11 | HTMLDivElement, 12 | QueueContainerProps 13 | // @ts-expect-error we disallow passing className 14 | >(({ children, className: _, ...props }, ref) => { 15 | return ( 16 | 21 |
22 | {children} 23 |
24 |
25 | ) 26 | }) 27 | -------------------------------------------------------------------------------- /src/routes/_pages/-components/queue-item.tsx: -------------------------------------------------------------------------------- 1 | import { revealItemInDir } from '@/lib/tauri/utils' 2 | import { bytesToString, getFileIcon } from '@/utils' 3 | import { DownloadQueueItem, UploadQueueItem } from '@/state/appstate' 4 | import { EllipsisVertical } from 'lucide-react' 5 | import { motion, MotionStyle } from 'motion/react' 6 | import { AnimatedCheckMark } from '@/components/animated-checkmark' 7 | import { ProgressBar } from './progress-bar' 8 | import { Button } from '@/components/ui/button' 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuTrigger, 13 | } from '@/components/ui/dropdown-menu' 14 | 15 | export type QueueItemProps = { 16 | item: UploadQueueItem | DownloadQueueItem 17 | dropdownContent?: React.ReactNode 18 | doneLabel: string 19 | style?: MotionStyle 20 | showProgress?: boolean 21 | } 22 | 23 | export function QueueItem({ 24 | item: { name, icon, size, progress, ...item }, 25 | style, 26 | dropdownContent, 27 | doneLabel, 28 | showProgress = true, 29 | }: QueueItemProps) { 30 | const hasPath = 'path' in item 31 | const fileType = name.split('.').pop()?.toLowerCase() || '' 32 | 33 | const iconEl = icon ? ( 34 |
35 | 36 |
37 | ) : ( 38 | getFileIcon(fileType) 39 | ) 40 | 41 | return ( 42 | 64 | 65 | {iconEl} 66 | 80 |

81 | {bytesToString(size)} 82 |

83 | {dropdownContent && ( 84 | 85 | 86 | 89 | 90 | {dropdownContent} 91 | 92 | )} 93 |
94 | 95 | {showProgress && !item.done && ( 96 | 101 | )} 102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/routes/_pages/edit-profile.tsx: -------------------------------------------------------------------------------- 1 | import { EditProfile } from '@/context/edit-profile' 2 | import { AppState } from '@/state/appstate' 3 | import { createFileRoute } from '@tanstack/react-router' 4 | 5 | export const Route = createFileRoute('/_pages/edit-profile')({ 6 | component: RouteComponent, 7 | }) 8 | 9 | function RouteComponent() { 10 | const { user } = AppState.use('user') 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /src/routes/_pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { cn } from '@/utils' 3 | import { createFileRoute } from '@tanstack/react-router' 4 | import { Download, Send } from 'lucide-react' 5 | import { motion } from 'motion/react' 6 | import { useState } from 'react' 7 | 8 | const BUTTONS = { 9 | send: { 10 | icon: Send, 11 | iconProps: { className: 'size-7! fill-foreground' }, 12 | text: 'Send', 13 | path: '/send', 14 | className: 15 | 'hover:bg-teal-500 hover:shadow-teal-600/40 dark:hover:bg-teal-700', 16 | firstIconVariants: { 17 | hovered: { scale: 0, y: -40, x: 40 }, 18 | default: { scale: 1, y: 0, x: 0 }, 19 | }, 20 | secondIconVariants: { 21 | default: { scale: 0, y: 40, x: -40 }, 22 | hovered: { scale: 1, y: 0, x: 0 }, 23 | }, 24 | }, 25 | receive: { 26 | icon: Download, 27 | iconProps: { className: 'size-7! stroke-[3px]' }, 28 | text: 'Receive', 29 | path: '/receive', 30 | className: 31 | 'hover:bg-emerald-500 hover:shadow-emerald-600/40 dark:hover:bg-emerald-700', 32 | firstIconVariants: { 33 | hovered: { scale: 0, y: 30, x: 0 }, 34 | default: { scale: 1, y: 0, x: 0 }, 35 | }, 36 | secondIconVariants: { 37 | default: { scale: 0, y: -30, x: 0 }, 38 | hovered: { scale: 1, y: 0, x: 0 }, 39 | }, 40 | }, 41 | } 42 | 43 | export const Route = createFileRoute('/_pages/')({ 44 | component: RouteComponent, 45 | }) 46 | 47 | function RouteComponent() { 48 | return ( 49 |
50 | 51 | 52 |
53 | ) 54 | } 55 | 56 | function AnimatedButton({ type }: { type: keyof typeof BUTTONS }) { 57 | const [hovered, setHovered] = useState(false) 58 | const navigate = Route.useNavigate() 59 | 60 | const config = BUTTONS[type] 61 | const Icon = config.icon 62 | 63 | return ( 64 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/routes/_pages/onboard.tsx: -------------------------------------------------------------------------------- 1 | import { EditProfile } from '@/context/edit-profile' 2 | import { AppState } from '@/state/appstate' 3 | import { createFileRoute } from '@tanstack/react-router' 4 | import { useEffect } from 'react' 5 | 6 | export const Route = createFileRoute('/_pages/onboard')({ 7 | component: RouteComponent, 8 | }) 9 | 10 | function RouteComponent() { 11 | const navigate = Route.useNavigate() 12 | const { user } = AppState.use('user') 13 | 14 | useEffect(() => { 15 | if (user) navigate({ to: '/' }) 16 | }, [user]) 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /src/routes/_pages/receive.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from '@/components/loader' 2 | import { Button } from '@/components/ui/button' 3 | import { DropdownMenuItem } from '@/components/ui/dropdown-menu' 4 | import { Input } from '@/components/ui/input' 5 | import { api } from '@/lib/tauri' 6 | import * as events from '@/lib/tauri/events' 7 | import { listeners } from '@/lib/tauri/utils' 8 | import { AppState, DownloadQueueItem } from '@/state/appstate' 9 | import { Throttle } from '@/utils' 10 | import { createFileRoute } from '@tanstack/react-router' 11 | import { motion, motionValue } from 'motion/react' 12 | import { useEffect, useRef } from 'react' 13 | import { toast } from 'sonner' 14 | import { QueueContainer } from './-components/queue-container' 15 | import { QueueItem } from './-components/queue-item' 16 | 17 | export const Route = createFileRoute('/_pages/receive')({ 18 | component: ReceivePage, 19 | }) 20 | 21 | function ReceivePage() { 22 | const inputRef = useRef(null) 23 | 24 | const store = AppState.use( 25 | 'isDownloading', 26 | 'clearDownloadQueue', 27 | 'downloadQueue', 28 | 'updateDownloadQueueItemProgress', 29 | 'addToDownloadQueue', 30 | 'removeFromDownloadQueue', 31 | ) 32 | 33 | async function download() { 34 | const ticket = inputRef.current?.value 35 | if (!ticket) return 36 | 37 | store.clearDownloadQueue() 38 | 39 | const downloadRes = await api.downloadHeader(ticket) 40 | if (downloadRes.isErr()) return 41 | 42 | const files = downloadRes.value 43 | 44 | for (const file of files) { 45 | api.downloadFile(file).then((res) => { 46 | if (res.isOk()) return 47 | 48 | store.removeFromDownloadQueue(file.name) 49 | toast.error(res.error, { 50 | description: file.name, 51 | }) 52 | }) 53 | } 54 | 55 | AppState.set({ isDownloading: true }) 56 | } 57 | 58 | return ( 59 |
60 |
61 | 66 | 81 |
82 | 83 | {Object.values(store.downloadQueue).map((item) => ( 84 | api.abortDownload(item.name)} 91 | className='cursor-pointer hover:bg-rose-400! dark:hover:bg-rose-600!' 92 | > 93 | Cancel 94 | 95 | ) 96 | } 97 | doneLabel='Download complete' 98 | /> 99 | ))} 100 | 101 |
102 | ) 103 | } 104 | 105 | export function ReceivePageListeners() { 106 | useEffect(() => { 107 | const store = AppState.get() 108 | const throttle = new Throttle(1000) 109 | 110 | const unsub = listeners({ 111 | [events.DOWNLOAD_FILE_ADDED]: (ev) => { 112 | const item = ev.payload as events.DownloadFileAdded as DownloadQueueItem 113 | item.progress = motionValue(0) 114 | store.addToDownloadQueue(item) 115 | }, 116 | 117 | [events.DOWNLOAD_FILE_PROGRESS]: (ev) => { 118 | let { name, progress, speed } = 119 | ev.payload as events.DownloadFileProgress 120 | if (throttle.isFree(name)) { 121 | store.updateDownloadQueueItemProgress(name, progress, speed) 122 | } 123 | }, 124 | 125 | [events.DOWNLOAD_FILE_COMPLETED]: (ev) => { 126 | let { name, path } = ev.payload as events.DownloadFileCompleted 127 | store.updateDownloadQueueItemPath(name, path) 128 | store.updateDownloadQueueItemProgress(name, 100, 0) 129 | }, 130 | 131 | [events.DOWNLOAD_ALL_COMPLETE]: () => { 132 | AppState.set({ isDownloading: false }) 133 | }, 134 | 135 | [events.DOWNLOAD_FILE_ERROR]: (ev) => { 136 | let { name, error } = ev.payload as events.DownloadFileError 137 | store.removeFromDownloadQueue(name) 138 | toast.error(error, { 139 | description: name, 140 | }) 141 | }, 142 | [events.DOWNLOAD_FILE_ABORTED]: (ev) => { 143 | let { name } = ev.payload as events.DownloadFileAborted 144 | store.removeFromDownloadQueue(name) 145 | toast('Download canceled', { 146 | description: name, 147 | }) 148 | }, 149 | }) 150 | return unsub 151 | }, []) 152 | return <> 153 | } 154 | -------------------------------------------------------------------------------- /src/routes/_pages/send.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatedCheckMark } from '@/components/animated-checkmark' 2 | import { QueueContainer } from './-components/queue-container' 3 | import { QueueItem } from './-components/queue-item' 4 | import { Button } from '@/components/ui/button' 5 | import { DropdownMenuItem } from '@/components/ui/dropdown-menu' 6 | import { events, api, copyText, listeners } from '@/lib/tauri' 7 | import { sleep, Throttle, ThrottledQueue } from '@/utils' 8 | import { AppState, UploadQueueItem } from '@/state/appstate' 9 | import { createFileRoute } from '@tanstack/react-router' 10 | import { open } from '@tauri-apps/plugin-dialog' 11 | import { Plus, Ticket, Trash, Trash2 } from 'lucide-react' 12 | import { motion, motionValue } from 'motion/react' 13 | import { useEffect, useRef, useState } from 'react' 14 | import { toast } from 'sonner' 15 | 16 | export const Route = createFileRoute('/_pages/send')({ 17 | component: SendPage, 18 | }) 19 | 20 | function SendPage() { 21 | const scrollAreaRef = useRef(null) 22 | 23 | const store = AppState.use( 24 | 'uploadDraggedItems', 25 | 'uploadQueue', 26 | 'addToUploadQueue', 27 | 'updateUploadQueueItemProgress', 28 | 'removeFromUploadQueue', 29 | ) 30 | const dragging = store.uploadDraggedItems.length > 0 31 | 32 | useEffect(() => { 33 | if (!dragging) return 34 | scrollAreaRef.current?.scrollTo({ 35 | top: 0, 36 | behavior: 'smooth', 37 | }) 38 | }, [dragging]) 39 | 40 | async function addFilesFromDialog() { 41 | const paths = await open({ multiple: true }) 42 | if (!paths) return 43 | api.addFiles(paths) 44 | } 45 | 46 | const queueSize = Object.values(store.uploadQueue).length 47 | 48 | const showEmptyMessage = !dragging && queueSize == 0 49 | 50 | return ( 51 | 55 |
56 |

{queueSize} files

57 | 64 | 67 |
68 | 69 | {showEmptyMessage && ( 70 | 71 |
72 |

73 | Oops! Looks like it's a bit empty here 🤔 74 |
75 | Add some files or drag and drop to start sharing the magic! ✨ 76 |

77 | 80 |
81 |
82 | )} 83 | {store.uploadDraggedItems.map((item) => { 84 | const queueItem: UploadQueueItem = { 85 | ...item, 86 | progress: motionValue(0), 87 | done: false, 88 | } 89 | return ( 90 |
91 | 92 |
93 | ) 94 | })} 95 | {Object.values(store.uploadQueue).map((item) => { 96 | return ( 97 | api.removeFile(item.path)} 104 | className='box-border cursor-pointer border border-transparent bg-gradient-to-br transition-none hover:border-rose-700 hover:from-rose-500 hover:to-rose-600 hover:text-white! hover:text-shadow-sm' 105 | > 106 | 107 | Delete 108 | 109 | } 110 | /> 111 | ) 112 | })} 113 |
114 | {queueSize > 0 && } 115 |
116 | ) 117 | } 118 | 119 | function CopyTicketButton() { 120 | const [copied, setCopied] = useState(false) 121 | 122 | const copyTicket = async () => { 123 | const ticketRes = await api.generateTicket() 124 | if (ticketRes.isErr()) return 125 | 126 | const copyRes = await copyText(ticketRes.value) 127 | if (copyRes.isErr()) return 128 | 129 | setCopied(true) 130 | await sleep(2000) 131 | setCopied(false) 132 | } 133 | 134 | return ( 135 | 150 | ) 151 | } 152 | 153 | export function SendPageListeners() { 154 | useEffect(() => { 155 | const isSendPage = () => window.location.pathname.startsWith('/send') 156 | const store = AppState.get() 157 | const throttle = new Throttle(32) 158 | const uploadQ = new ThrottledQueue(10, 100) 159 | uploadQ.onRelease(store.addToUploadQueue) 160 | 161 | const unsub = listeners({ 162 | [events.UPLOAD_FILE_ADDED]: (event) => { 163 | /* We are clearing this here to prevent the empty message to flicker after drag-drop 164 | event when the items are cleared and until the file is received from backend. */ 165 | const item = event.payload as events.UploadFileAdded as UploadQueueItem 166 | item.progress = motionValue(0) 167 | const queue = AppState.get().uploadQueue 168 | if (item.name in queue) return 169 | uploadQ.add(item) 170 | }, 171 | 172 | [events.UPLOAD_FILE_PROGRESS]: (event) => { 173 | const file = event.payload as events.UploadFileProgress 174 | if (throttle.isFree(file.path)) { 175 | store.updateUploadQueueItemProgress(file.path, file.progress) 176 | } 177 | }, 178 | 179 | [events.UPLOAD_FILE_COMPLETED]: (event) => { 180 | const { name } = event.payload as events.UploadFileCompleted 181 | 182 | // We mark the item as done here to prevent hanging, since these might 183 | // get added to the queue after the import is complete in the background due 184 | // to our throttling queue mechanism. 185 | let item = uploadQ.find((i) => i.name == name) 186 | if (item) { 187 | item.done = true 188 | return 189 | } 190 | 191 | store.updateUploadQueueItemProgress(name, 100) 192 | }, 193 | 194 | [events.UPLOAD_FILE_REMOVED]: (event) => { 195 | const { name } = event.payload as events.UploadFileRemoved 196 | store.removeFromUploadQueue(name) 197 | }, 198 | 199 | [events.UPLOAD_FILE_ERROR]: (event) => { 200 | const { name, error } = event.payload as events.UploadFileError 201 | store.removeFromUploadQueue(name) 202 | toast.error(error, { 203 | description: name, 204 | }) 205 | }, 206 | 207 | 'tauri://drag-enter': async (event) => { 208 | if (!isSendPage()) return 209 | let uploadQueueSet = new Set( 210 | Object.values(AppState.get().uploadQueue).map((i) => i.name), 211 | ) 212 | const paths = event.payload.paths as string[] 213 | 214 | const filesRes = await api.validateFiles(paths) 215 | if (filesRes.isErr()) return 216 | 217 | const files = filesRes.value.filter( 218 | (file) => !uploadQueueSet.has(file.name), 219 | ) 220 | 221 | AppState.set({ uploadDraggedItems: files }) 222 | }, 223 | 224 | 'tauri://drag-leave': () => { 225 | AppState.set({ uploadDraggedItems: [] }) 226 | }, 227 | 228 | 'tauri://drag-drop': async () => { 229 | if (!isSendPage()) return 230 | const store = AppState.get() 231 | let newQueue: Record = {} 232 | 233 | let paths = [] 234 | for (const file of store.uploadDraggedItems.reverse()) { 235 | newQueue[file.name] = { 236 | ...file, 237 | progress: motionValue(0), 238 | done: false, 239 | } 240 | paths.push(file.path) 241 | } 242 | 243 | api.addFiles(paths) 244 | 245 | newQueue = { ...newQueue, ...store.uploadQueue } 246 | AppState.set({ 247 | uploadDraggedItems: [], 248 | uploadQueue: newQueue, 249 | }) 250 | }, 251 | }) 252 | 253 | return unsub 254 | }, []) 255 | 256 | return <> 257 | } 258 | -------------------------------------------------------------------------------- /src/state/appstate.tsx: -------------------------------------------------------------------------------- 1 | import { ValidatedFile } from '@/lib/tauri' 2 | import { User } from '@/lib/tauri/api' 3 | import { createSelector } from '@/lib/zustand' 4 | import { MotionValue } from 'motion/react' 5 | import { create } from 'zustand' 6 | 7 | export type UploadQueueItem = { 8 | name: string 9 | icon: string 10 | size: number 11 | progress: MotionValue 12 | done: boolean 13 | path: string 14 | } 15 | 16 | export type DownloadQueueItem = UploadQueueItem & { 17 | speed: number 18 | } 19 | 20 | type AppState = { 21 | user: User | null 22 | isDownloading: boolean 23 | 24 | downloadQueue: Record 25 | uploadQueue: Record 26 | uploadDraggedItems: ValidatedFile[] 27 | 28 | addToUploadQueue: (files: UploadQueueItem[]) => void 29 | addToDownloadQueue: (file: DownloadQueueItem) => void 30 | 31 | removeFromDownloadQueue: (fileName: string) => void 32 | removeFromUploadQueue: (fileName: string) => void 33 | 34 | updateUploadQueueItemProgress: (path: string, progress: number) => void 35 | updateDownloadQueueItemProgress: ( 36 | fileName: string, 37 | progress: number, 38 | speed: number, 39 | ) => void 40 | 41 | updateDownloadQueueItemPath: (name: string, path: string) => void 42 | clearDownloadQueue: () => void 43 | reorderUploadQueue: () => void 44 | } 45 | 46 | const store = create((set, get) => ({ 47 | user: null, 48 | isDownloading: false, 49 | 50 | downloadQueue: {}, 51 | uploadQueue: {}, 52 | uploadDraggedItems: [], 53 | 54 | addToDownloadQueue: (file: DownloadQueueItem) => 55 | set((state) => ({ 56 | downloadQueue: { ...state.downloadQueue, [file.name]: file }, 57 | })), 58 | 59 | addToUploadQueue: (files: UploadQueueItem[]) => 60 | set((s) => { 61 | const queue = { ...s.uploadQueue } 62 | for (const file of files) { 63 | queue[file.name] = file 64 | } 65 | return { uploadQueue: queue } 66 | }), 67 | 68 | removeFromDownloadQueue: (fileName: string) => 69 | set((state) => { 70 | const downloadQueue = { ...state.downloadQueue } 71 | delete downloadQueue[fileName] 72 | return { downloadQueue } 73 | }), 74 | 75 | removeFromUploadQueue: (fileName: string) => 76 | set((state) => { 77 | const uploadQueue = { ...state.uploadQueue } 78 | delete uploadQueue[fileName] 79 | return { uploadQueue } 80 | }), 81 | 82 | updateUploadQueueItemProgress: (filename: string, progress: number) => { 83 | const entry = get().uploadQueue[filename] 84 | entry!.progress.set(progress) 85 | if (progress < 100) return 86 | 87 | set((s) => ({ 88 | uploadQueue: { 89 | ...s.uploadQueue, 90 | [filename]: { 91 | ...entry, 92 | done: true, 93 | }, 94 | }, 95 | })) 96 | }, 97 | 98 | updateDownloadQueueItemProgress: ( 99 | filename: string, 100 | progress: number, 101 | speed: number, 102 | ) => { 103 | const entry = get().downloadQueue[filename] 104 | entry!.progress.set(progress) 105 | 106 | return set((state) => ({ 107 | downloadQueue: { 108 | ...state.downloadQueue, 109 | [filename]: { 110 | ...entry, 111 | speed, 112 | done: progress == 100, 113 | }, 114 | }, 115 | })) 116 | }, 117 | 118 | updateDownloadQueueItemPath: (filename: string, path: string) => { 119 | const entry = get().downloadQueue[filename] 120 | return set((state) => ({ 121 | downloadQueue: { 122 | ...state.downloadQueue, 123 | [filename]: { 124 | ...entry, 125 | path, 126 | }, 127 | }, 128 | })) 129 | }, 130 | clearDownloadQueue: () => set({ downloadQueue: {} }), 131 | 132 | reorderUploadQueue: () => { 133 | const uploadQueue = get().uploadQueue 134 | const newQueue: Record = {} 135 | Object.keys(uploadQueue) 136 | .sort((a, b) => Number(uploadQueue[b].size) - Number(uploadQueue[a].size)) 137 | .forEach((key) => { 138 | newQueue[key] = uploadQueue[key] 139 | }) 140 | return set({ uploadQueue: newQueue }) 141 | }, 142 | })) 143 | 144 | const use = createSelector(store) 145 | 146 | export const AppState = { 147 | use, 148 | get: store.getState, 149 | set: store.setState, 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/async.ts: -------------------------------------------------------------------------------- 1 | /** Sleep for a given number of milliseconds */ 2 | export function sleep(ms: number) { 3 | return new Promise((resolve) => setTimeout(resolve, ms)) 4 | } 5 | 6 | /** 7 | * # Throttle 8 | * Throttle class provides rate limiting based on a delay period. 9 | * Supports both global and key-specific throttling. 10 | * 11 | * __Example usage:__ 12 | * ```ts 13 | * const throttle = new Throttle(1000); // 1 second delay 14 | * if (throttle.isFree()) { 15 | * // perform operation 16 | * } 17 | * ``` 18 | */ 19 | export class Throttle { 20 | private lastEmitMap: Record = {} 21 | private lastEmit: number = 0 22 | 23 | constructor(private delay: number) { 24 | this.lastEmit = 0 - delay 25 | } 26 | 27 | /** 28 | * Checks if the throttle is free to emit. Returns true if the throttle is free, false otherwise. 29 | * @param key optional param which allows for throttling of specific keys only 30 | * @returns 31 | */ 32 | isFree(key?: string): boolean { 33 | if (key != undefined) { 34 | return this.isFree_Key(key) 35 | } 36 | return this.isFree_NoKey() 37 | } 38 | 39 | private isFree_NoKey(): boolean { 40 | const now = Date.now() 41 | const elapsed = now - this.lastEmit 42 | if (elapsed >= this.delay) { 43 | this.lastEmit = now 44 | return true 45 | } 46 | return false 47 | } 48 | 49 | private isFree_Key(key: string): boolean { 50 | const now = Date.now() 51 | if (!(key in this.lastEmitMap)) { 52 | this.lastEmitMap[key] = now 53 | return true 54 | } 55 | const lastEmit = this.lastEmitMap[key] 56 | 57 | const elapsed = now - lastEmit 58 | if (elapsed >= this.delay) { 59 | this.lastEmitMap[key] = now 60 | return true 61 | } 62 | return false 63 | } 64 | } 65 | 66 | /** 67 | * # Throttled Queue 68 | * ThrottledQueue class provides a queue mechanism with a maximum size and a timeout-based release system. 69 | * Items are added to the queue, and when the queue reaches its maximum size or the timeout expires, 70 | * the queue is released via a callback function. 71 | * 72 | * __Example usage:__ 73 | * ```ts 74 | * const queue = new ThrottledQueue(5, 1000); // Max size 5, timeout 1 second 75 | * queue.onRelease((items) => { 76 | * console.log('Released items:', items); 77 | * }); 78 | * queue.add('item1'); 79 | * queue.add('item2'); 80 | * ``` 81 | */ 82 | export class ThrottledQueue { 83 | private queue: T[] = [] 84 | private releaseCallback: (queue: T[]) => void = () => {} 85 | private timerId: number | null = null 86 | 87 | constructor( 88 | private size = 10, 89 | private timeout = 200, 90 | ) { 91 | this.startTimer() 92 | } 93 | 94 | private release() { 95 | if (this.queue.length > 0) { 96 | // Only release if the queue is not empty 97 | this.releaseCallback(this.queue) 98 | this.queue = [] 99 | } 100 | } 101 | 102 | private startTimer() { 103 | if (this.timerId !== null) { 104 | clearTimeout(this.timerId) 105 | } 106 | this.timerId = setTimeout(() => { 107 | this.release() 108 | this.startTimer() 109 | }, this.timeout) 110 | } 111 | 112 | find(predicate: (a: T) => boolean) { 113 | return this.queue.find(predicate) 114 | } 115 | 116 | onRelease(cb: typeof this.releaseCallback) { 117 | this.releaseCallback = cb 118 | } 119 | 120 | add(item: T) { 121 | this.queue.push(item) 122 | if (this.queue.length >= this.size) { 123 | this.release() 124 | this.startTimer() 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | export function getFileIcon(fileType: string) { 2 | switch (fileType) { 3 | case 'pdf': 4 | return '📄' 5 | case 'doc': 6 | case 'docx': 7 | return '📝' 8 | case 'xls': 9 | case 'xlsx': 10 | return '📊' 11 | case 'ppt': 12 | case 'pptx': 13 | return '📑' 14 | case 'jpg': 15 | case 'jpeg': 16 | case 'png': 17 | case 'gif': 18 | case 'bmp': 19 | return '🖼️' 20 | case 'mp3': 21 | case 'wav': 22 | case 'ogg': 23 | return '🎵' 24 | case 'mp4': 25 | case 'mov': 26 | case 'avi': 27 | return '🎥' 28 | case 'zip': 29 | case 'rar': 30 | case '7z': 31 | return '📦' 32 | case 'exe': 33 | return '⚙️' 34 | case 'txt': 35 | return '📄' 36 | default: 37 | return '📄' 38 | } 39 | } 40 | 41 | export function bytesToString(bytes: number) { 42 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 43 | if (bytes === 0) return '0 B' 44 | const i = Math.floor(Math.log(bytes) / Math.log(1024)) 45 | return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}` 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './async' 2 | export * from './fs' 3 | export * from './webview' 4 | export * from './style' 5 | export * from './type-utils' 6 | -------------------------------------------------------------------------------- /src/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/type-utils.ts: -------------------------------------------------------------------------------- 1 | export type AutocompleteOnlyString = T | (string & {}) 2 | -------------------------------------------------------------------------------- /src/utils/webview.ts: -------------------------------------------------------------------------------- 1 | export function disableBrowserDefaultBehaviours() { 2 | // DISABLE RIGHT CLICK CONTEXT MENU 3 | document.addEventListener('contextmenu', (e) => { 4 | if (import.meta.env.DEV) return 5 | e.preventDefault() 6 | }) 7 | 8 | // DISABLE RELOADS 9 | document.addEventListener('keydown', (event) => { 10 | if (import.meta.env.DEV) return 11 | // Prevent F5 or Ctrl+R (Windows/Linux) and Command+R (Mac) from refreshing the page 12 | const shouldBlock = 13 | event.key === 'F5' || 14 | (event.ctrlKey && event.key === 'r') || 15 | (event.metaKey && event.key === 'r') 16 | 17 | if (shouldBlock) event.preventDefault() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { TanStackRouterVite } from '@tanstack/router-plugin/vite' 4 | import TailwindCSS from '@tailwindcss/vite' 5 | 6 | // @ts-expect-error process is a nodejs global 7 | const host = process.env.TAURI_DEV_HOST 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), 13 | react(), 14 | TailwindCSS(), 15 | ], 16 | 17 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 18 | // 19 | // 1. prevent vite from obscuring rust errors 20 | clearScreen: false, 21 | // 2. tauri expects a fixed port, fail if that port is not available 22 | resolve: { 23 | alias: { 24 | '@': '/src', 25 | }, 26 | }, 27 | server: { 28 | port: 1420, 29 | strictPort: true, 30 | host: host || false, 31 | hmr: host 32 | ? { 33 | protocol: 'ws', 34 | host, 35 | port: 1421, 36 | } 37 | : undefined, 38 | watch: { 39 | // 3. tell vite to ignore watching `src-tauri` 40 | ignored: ['**/src-tauri/**'], 41 | }, 42 | }, 43 | }) 44 | --------------------------------------------------------------------------------