├── .cargo └── config.toml ├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .mailmap ├── Cargo.lock ├── Cargo.toml ├── Justfile ├── assets ├── inter │ ├── Inter-Bold.otf │ ├── Inter-Medium.otf │ └── LICENSE.txt └── lucide │ ├── LICENSE │ ├── lucide.ttf │ └── readme.md ├── build.rs ├── build ├── macos │ ├── AppIcon.iconset │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── create_icns.sh │ ├── icon_1024x1024.png │ └── src │ │ └── Project.app │ │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ └── AppIcon.icns ├── readme.md └── windows │ ├── icon-256x256.png │ ├── icon-32x32.png │ ├── installer │ ├── Package.en-us.wxl │ ├── Package.wxs │ └── WixUI_InstallDir.wxs │ └── portal.ico ├── crates ├── portal-proc-macro │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── portal-wormhole │ ├── Cargo.toml │ └── src │ ├── cancellation.rs │ ├── error.rs │ ├── fs.rs │ ├── fs │ ├── download.rs │ ├── filename.rs │ ├── path_parts.rs │ └── persist.rs │ ├── lib.rs │ ├── receive.rs │ ├── send.rs │ ├── send │ ├── request.rs │ └── sendable_file.rs │ ├── sync.rs │ ├── temp_zip.rs │ └── transit.rs ├── license-apache.txt ├── license-mit.txt ├── readme.md ├── screenshots ├── receive-dark.png ├── receive-light.png ├── send-dark.png └── send-light.png ├── src ├── auto_viewport_theme.rs ├── egui_ext.rs ├── font.rs ├── lib.rs ├── main.rs ├── main_view.rs ├── receive.rs ├── send.rs ├── startup_action.rs ├── transit_info.rs ├── version.rs ├── visuals.rs ├── widgets.rs └── widgets │ ├── cancel.rs │ ├── menu.rs │ ├── page.rs │ ├── primary_button.rs │ └── toggle.rs └── xtask ├── Cargo.toml └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --package xtask --" 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | os: [windows-latest, ubuntu-latest, macos-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/cache@v4 19 | with: 20 | path: | 21 | ~/.cargo/bin/ 22 | ~/.cargo/registry/index/ 23 | ~/.cargo/registry/cache/ 24 | ~/.cargo/git/db/ 25 | target/ 26 | key: ${{ runner.os }}-cargo-build-nightly-${{ hashFiles('**/Cargo.toml') }} 27 | - name: Install dependencies 28 | run: | 29 | sudo apt-get install librust-atk-dev libgtk-3-dev 30 | if: runner.os == 'linux' 31 | - name: Build & run tests 32 | run: cargo test 33 | test-docs: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/cache@v4 38 | with: 39 | path: | 40 | ~/.cargo/bin/ 41 | ~/.cargo/registry/index/ 42 | ~/.cargo/registry/cache/ 43 | ~/.cargo/git/db/ 44 | target/ 45 | key: ubuntu-latest-cargo-build-nightly-${{ hashFiles('**/Cargo.toml') }} 46 | - name: Install dependencies 47 | run: | 48 | sudo apt-get install librust-atk-dev libgtk-3-dev 49 | - name: Run doc tests with all features (this also compiles README examples) 50 | run: cargo test --doc --all-features 51 | lint: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/cache@v4 56 | with: 57 | path: | 58 | ~/.cargo/bin/ 59 | ~/.cargo/registry/index/ 60 | ~/.cargo/registry/cache/ 61 | ~/.cargo/git/db/ 62 | target/ 63 | key: ubuntu-latest-cargo-build-nightly-${{ hashFiles('**/Cargo.toml') }} 64 | - run: rustup component add rustfmt 65 | - run: rustup component add clippy 66 | - name: Install dependencies 67 | run: | 68 | sudo apt-get install librust-atk-dev libgtk-3-dev 69 | - name: Check format 70 | run: cargo fmt --all -- --check 71 | - name: Run clippy 72 | run: cargo clippy --workspace --all-targets --all-features -- --deny warnings 73 | - name: Check for typos 74 | uses: crate-ci/typos@v1.21.0 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-flow 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+*" 7 | 8 | env: 9 | # heads-up: this value is used as a pattern in a sed command as a workaround for a trunk issue 10 | # if you use special characters, take a look at the 'Make paths relative' step in the 'build-web' job 11 | EXECUTABLE_NAME: portal 12 | OSX_APP_NAME: Portal 13 | CARGO_TERM_COLOR: always 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | build-macOS: 20 | runs-on: macos-latest 21 | 22 | env: 23 | # Minimum version of macOS that the executable will support 24 | MACOSX_DEPLOYMENT_TARGET: 11.0 # (11.0 is Big Sur from 2020) 25 | 26 | steps: 27 | - name: Get tag 28 | id: tag 29 | uses: dawidd6/action-get-tag@v1 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | - name: Remove build script 33 | run: | 34 | rm build.rs 35 | - name: Install rust toolchain for Apple Silicon 36 | run: rustup target add aarch64-apple-darwin 37 | - name: Build release for Apple Silicon 38 | run: | 39 | SDKROOT=$(xcrun -sdk macosx --show-sdk-path) cargo build --release --target=aarch64-apple-darwin 40 | - name: Install rust toolchain for Apple x86 41 | run: rustup target add x86_64-apple-darwin 42 | - name: Build release for x86 Apple 43 | run: | 44 | SDKROOT=$(xcrun -sdk macosx --show-sdk-path) cargo build --release --target=x86_64-apple-darwin 45 | - name: Create Universal Binary 46 | run: | 47 | lipo -create -output target/release/${{ env.EXECUTABLE_NAME }} target/aarch64-apple-darwin/release/${{ env.EXECUTABLE_NAME }} target/x86_64-apple-darwin/release/${{ env.EXECUTABLE_NAME }} 48 | - name: Create release 49 | run: | 50 | mkdir -p build/macos/src/Project.app/Contents/MacOS 51 | cp target/release/${{ env.EXECUTABLE_NAME }} build/macos/src/Project.app/Contents/MacOS/ 52 | strip build/macos/src/Project.app/Contents/MacOS/${{ env.EXECUTABLE_NAME }} 53 | mv build/macos/src/Project.app build/macos/src/${{ env.OSX_APP_NAME }}.app 54 | ln -s /Applications build/macos/src/ 55 | hdiutil create -fs HFS+ -volname "${{ env.OSX_APP_NAME }}" -srcfolder build/macos/src ${{ env.EXECUTABLE_NAME }}.dmg 56 | - name: Upload release 57 | uses: svenstaro/upload-release-action@v2 58 | with: 59 | repo_token: ${{ secrets.GITHUB_TOKEN }} 60 | file: ${{ env.EXECUTABLE_NAME }}.dmg 61 | asset_name: ${{ env.EXECUTABLE_NAME }}_${{ steps.tag.outputs.tag }}_macOS.dmg 62 | tag: ${{ github.ref }} 63 | overwrite: true 64 | 65 | build-linux: 66 | runs-on: ubuntu-latest 67 | 68 | steps: 69 | - name: Get tag 70 | id: tag 71 | uses: dawidd6/action-get-tag@v1 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | - name: Install dependencies 75 | run: | 76 | sudo apt-get install librust-atk-dev libgtk-3-dev 77 | - name: Build release 78 | run: | 79 | cargo build --release 80 | - name: Prepare release 81 | run: | 82 | strip target/release/${{ env.EXECUTABLE_NAME }} 83 | chmod +x target/release/${{ env.EXECUTABLE_NAME }} 84 | mv target/release/${{ env.EXECUTABLE_NAME }} . 85 | - name: Bundle release 86 | run: | 87 | tar -czf ${{ env.EXECUTABLE_NAME }}_linux.tar.gz ${{ env.EXECUTABLE_NAME }} 88 | - name: Upload release 89 | uses: svenstaro/upload-release-action@v2 90 | with: 91 | repo_token: ${{ secrets.GITHUB_TOKEN }} 92 | file: ${{ env.EXECUTABLE_NAME }}_linux.tar.gz 93 | asset_name: ${{ env.EXECUTABLE_NAME }}_${{ steps.tag.outputs.tag }}_linux.tar.gz 94 | tag: ${{ github.ref }} 95 | overwrite: true 96 | 97 | build-windows: 98 | runs-on: windows-latest 99 | 100 | steps: 101 | - name: Get tag 102 | id: tag 103 | uses: dawidd6/action-get-tag@v1 104 | - name: Checkout repository 105 | uses: actions/checkout@v4 106 | - uses: taiki-e/install-action@v2 107 | with: 108 | tool: just 109 | - name: Build 110 | run: cargo build --release 111 | - name: Create Installer 112 | run: just build-windows-installer 113 | - name: Zip release 114 | uses: vimtor/action-zip@v1 115 | with: 116 | files: target/release/${{ env.EXECUTABLE_NAME }}.exe 117 | dest: ${{ env.EXECUTABLE_NAME }}_windows.zip 118 | - name: Upload release 119 | uses: svenstaro/upload-release-action@v2 120 | with: 121 | repo_token: ${{ secrets.GITHUB_TOKEN }} 122 | file: ${{ env.EXECUTABLE_NAME }}_windows.zip 123 | asset_name: ${{ env.EXECUTABLE_NAME }}_${{ steps.tag.outputs.tag }}_windows.zip 124 | tag: ${{ github.ref }} 125 | overwrite: true 126 | - name: Upload release 127 | uses: svenstaro/upload-release-action@v2 128 | with: 129 | repo_token: ${{ secrets.GITHUB_TOKEN }} 130 | file: target/windows-installer/portal-installer.msi 131 | asset_name: ${{ env.EXECUTABLE_NAME }}_${{ steps.tag.outputs.tag }}_windows.msi 132 | tag: ${{ github.ref }} 133 | overwrite: true 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tau Gärtli <4602612+bash@users.noreply.github.com> 2 | Tau Gärtli 3 | Jan Hohenheim 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "portal" 3 | version = "0.2.3" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | repository = "https://github.com/bash/portal" 7 | 8 | [lib] 9 | name = "portal" 10 | path = "src/lib.rs" 11 | 12 | [[bin]] 13 | name = "portal" 14 | path = "src/main.rs" 15 | 16 | [dependencies] 17 | async-std = "1.12.0" 18 | clap = { version = "4.1.8", features = ["derive"] } 19 | color-hex = "0.2.0" 20 | eframe = { version = "0.30.0", features = ["persistence"] } 21 | egui = { version = "0.30.0", features = ["color-hex"] } 22 | futures = "0.3.26" 23 | opener = { version = "0.7.0", features = ["reveal"] } 24 | poll-promise = { version = "0.3.0", features = ["async-std"] } 25 | portal-proc-macro = { path = "crates/portal-proc-macro" } 26 | portal-wormhole = { path = "crates/portal-wormhole" } 27 | rfd = { version = "0.15.1" } 28 | replace_with = "0.1.7" 29 | tracing-subscriber = "0.3" 30 | ubyte = "0.10.3" 31 | thiserror = "2.0.9" 32 | surf = "2.3.2" 33 | serde = { version = "1.0.164", features = ["derive"] } 34 | log = { version = "0.4.19", features = ["kv"] } 35 | egui-theme-switch = { version = "0.2.3" } 36 | 37 | [lints] 38 | workspace = true 39 | 40 | [workspace] 41 | members = ["crates/portal-proc-macro", "crates/portal-wormhole", "xtask"] 42 | 43 | [workspace.lints.clippy] 44 | out_of_bounds_indexing = "allow" 45 | str_to_string = "warn" 46 | unwrap_used = "warn" 47 | undocumented_unsafe_blocks = "deny" # Can't have forbid here because #[derive(Parser)] wants to allow all clippy restrictions. 48 | 49 | [target.'cfg(windows)'.build-dependencies] 50 | winresource = "0.1.15" 51 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set windows-shell := ["powershell"] 2 | 3 | default: 4 | just --list 5 | 6 | build-windows-installer: 7 | dotnet tool install --global wix --version 5.0.1 8 | wix extension add WixToolset.UI.wixext/5.0.1 --global 9 | wix build \ 10 | build/windows/installer/Package.wxs \ 11 | build/windows/installer/WixUI_InstallDir.wxs \ 12 | build/windows/installer/Package.en-us.wxl \ 13 | -ext WixToolset.UI.wixext \ 14 | -o target/windows-installer/portal-installer \ 15 | -bindpath build/windows/installer 16 | -------------------------------------------------------------------------------- /assets/inter/Inter-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/assets/inter/Inter-Bold.otf -------------------------------------------------------------------------------- /assets/inter/Inter-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/assets/inter/Inter-Medium.otf -------------------------------------------------------------------------------- /assets/inter/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2020 The Inter Project Authors. 2 | "Inter" is trademark of Rasmus Andersson. 3 | https://github.com/rsms/inter 4 | 5 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 6 | This license is copied below, and is also available with a FAQ at: 7 | http://scripts.sil.org/OFL 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font creation 16 | efforts of academic and linguistic communities, and to provide a free and 17 | open framework in which fonts may be shared and improved in partnership 18 | with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply 27 | to any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software components as 38 | distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, deleting, 41 | or substituting -- in part or in whole -- any of the components of the 42 | Original Version, by changing formats or by porting the Font Software to a 43 | new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION AND CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 51 | redistribute, and sell modified and unmodified copies of the Font 52 | Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, 55 | in Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the corresponding 66 | Copyright Holder. This restriction only applies to the primary font name as 67 | presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created 79 | using the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /assets/lucide/LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /assets/lucide/lucide.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/assets/lucide/lucide.ttf -------------------------------------------------------------------------------- /assets/lucide/readme.md: -------------------------------------------------------------------------------- 1 | # [Lucide Icons](https://github.com/lucide-icons/lucide) 2 | 3 | ## Update Icons 4 | 1. Run `npm view lucide-static dist.tarball` 5 | 2. Download archive 6 | 3. Copy ttf file 7 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | fn main() -> Result<(), Box> { 3 | #[cfg(windows)] 4 | build_windows()?; 5 | Ok(()) 6 | } 7 | 8 | #[cfg(windows)] 9 | fn build_windows() -> Result<(), Box> { 10 | let mut res = winresource::WindowsResource::new(); 11 | res.set_icon("build/windows/portal.ico"); 12 | res.compile()?; 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /build/macos/AppIcon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/AppIcon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /build/macos/create_icns.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | rm -rf AppIcon.iconset/* 4 | mkdir -p AppIcon.iconset 5 | sips -z 16 16 icon_1024x1024.png --out AppIcon.iconset/icon_16x16.png 6 | sips -z 32 32 icon_1024x1024.png --out AppIcon.iconset/icon_16x16@2x.png 7 | sips -z 32 32 icon_1024x1024.png --out AppIcon.iconset/icon_32x32.png 8 | sips -z 64 64 icon_1024x1024.png --out AppIcon.iconset/icon_32x32@2x.png 9 | sips -z 128 128 icon_1024x1024.png --out AppIcon.iconset/icon_128x128.png 10 | sips -z 256 256 icon_1024x1024.png --out AppIcon.iconset/icon_128x128@2x.png 11 | sips -z 256 256 icon_1024x1024.png --out AppIcon.iconset/icon_256x256.png 12 | sips -z 512 512 icon_1024x1024.png --out AppIcon.iconset/icon_256x256@2x.png 13 | sips -z 512 512 icon_1024x1024.png --out AppIcon.iconset/icon_512x512.png 14 | cp icon_1024x1024.png AppIcon.iconset/icon_512x512@2x.png 15 | iconutil -c icns AppIcon.iconset 16 | mkdir -p src/Project.app/Contents/Resources 17 | mv AppIcon.icns src/Project.app/Contents/Resources/ 18 | -------------------------------------------------------------------------------- /build/macos/icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/icon_1024x1024.png -------------------------------------------------------------------------------- /build/macos/src/Project.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | Portal 9 | CFBundleExecutable 10 | portal 11 | CFBundleIconFile 12 | AppIcon.icns 13 | CFBundleIdentifier 14 | garden.tau.portal 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Portal 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 23 | 0.2.3 24 | CFBundleSupportedPlatforms 25 | 26 | MacOSX 27 | 28 | 29 | LSFileQuarantineEnabled 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /build/macos/src/Project.app/Contents/Resources/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/macos/src/Project.app/Contents/Resources/AppIcon.icns -------------------------------------------------------------------------------- /build/readme.md: -------------------------------------------------------------------------------- 1 | # Updating the icons 2 | Replace `build/windows/icon.ico` (used for windows executable) 3 | Replace `build/macos/icon_1024x1024.png` with a `1024` times `1024` pixel png icon and run `create_icns.sh` (make sure to run the script inside the `macos` directory) - Warning: sadly this seems to require a mac... 4 | -------------------------------------------------------------------------------- /build/windows/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/windows/icon-256x256.png -------------------------------------------------------------------------------- /build/windows/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/windows/icon-32x32.png -------------------------------------------------------------------------------- /build/windows/installer/Package.en-us.wxl: -------------------------------------------------------------------------------- 1 |  4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /build/windows/installer/Package.wxs: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /build/windows/installer/WixUI_InstallDir.wxs: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /build/windows/portal.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/build/windows/portal.ico -------------------------------------------------------------------------------- /crates/portal-proc-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "portal-proc-macro" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | proc-macro = true 8 | 9 | [dependencies] 10 | heck = "0.5.0" 11 | proc-macro2 = "1.0.51" 12 | quote = "1.0.23" 13 | syn = { version = "2.0.40", features = ["full"] } 14 | 15 | [lints] 16 | workspace = true 17 | -------------------------------------------------------------------------------- /crates/portal-proc-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | 3 | use heck::ToSnakeCase; 4 | use proc_macro::TokenStream; 5 | use proc_macro2::TokenStream as TokenStream2; 6 | use quote::{quote, ToTokens}; 7 | use syn::parse::{Parse, ParseStream}; 8 | use syn::punctuated::Punctuated; 9 | use syn::{ 10 | braced, parenthesized, parse_macro_input, Arm, Error, Expr, FnArg, Ident, Token, Type, 11 | Visibility, 12 | }; 13 | 14 | #[proc_macro] 15 | pub fn states(input: TokenStream) -> TokenStream { 16 | let StatesEnum { 17 | visibility, 18 | enum_token, 19 | ident, 20 | states, 21 | } = parse_macro_input!(input as StatesEnum); 22 | 23 | let state_variants: Punctuated<_, Token![,]> = states.iter().map(quote_enum_variant).collect(); 24 | let next_impl = quote_next_impl(&ident, &states); 25 | let new_fns = quote_new_fns(&ident, &states); 26 | let expanded_enum = quote! { 27 | #visibility #enum_token #ident { 28 | #state_variants 29 | } 30 | 31 | impl #ident { 32 | #next_impl 33 | #new_fns 34 | } 35 | }; 36 | 37 | TokenStream::from(expanded_enum) 38 | } 39 | 40 | fn quote_enum_variant( 41 | State { 42 | ident, 43 | fields, 44 | async_, 45 | }: &State, 46 | ) -> TokenStream2 { 47 | let promise_field = async_.as_ref().map( 48 | |AsyncState { 49 | output: execute_output, 50 | .. 51 | }| quote! { ::poll_promise::Promise<#execute_output>, }, 52 | ); 53 | let quoted_fields: Punctuated<_, Token![,]> = 54 | fields.iter().map(|StateField { ty, .. }| ty).collect(); 55 | quote! { #ident(#promise_field #quoted_fields) } 56 | } 57 | 58 | fn quote_next_impl(ident: &Ident, states: &[State]) -> TokenStream2 { 59 | let next_match_arms: Punctuated<_, Token![,]> = states 60 | .iter() 61 | .filter_map(|state| { 62 | state 63 | .async_ 64 | .as_ref() 65 | .map(|async_| quote_state_next_impl(state, async_)) 66 | }) 67 | .collect(); 68 | quote! { 69 | fn next(&mut self, ui: &mut ::egui::Ui) { 70 | use #ident::*; 71 | ::replace_with::replace_with(self, ::std::default::Default::default, |__state| { 72 | match __state { 73 | #next_match_arms, 74 | _ => __state, 75 | } 76 | }); 77 | } 78 | } 79 | } 80 | 81 | fn quote_state_next_impl( 82 | State { ident, fields, .. }: &State, 83 | AsyncState { next_arms, .. }: &AsyncState, 84 | ) -> TokenStream2 { 85 | let fields_quoted: Punctuated<_, Token![,]> = fields 86 | .iter() 87 | .map(|StateField { ident, .. }| ident.to_token_stream()) 88 | .collect(); 89 | let next_arms_quoted: TokenStream2 = 90 | next_arms.iter().map(|arm| arm.to_token_stream()).collect(); 91 | quote! { 92 | #ident(__state_promise, #fields_quoted) => match __state_promise.try_take() { 93 | Ok(__state_promise_ok) => match __state_promise_ok { #next_arms_quoted }, 94 | Err(__state_promise) => #ident(__state_promise, #fields_quoted), 95 | } 96 | } 97 | } 98 | 99 | fn quote_new_fns(ident: &Ident, states: &[State]) -> TokenStream2 { 100 | states 101 | .iter() 102 | .filter_map(|state| { 103 | state 104 | .async_ 105 | .as_ref() 106 | .map(|async_| quote_state_new_impl(ident, state, async_)) 107 | }) 108 | .collect() 109 | } 110 | 111 | fn quote_state_new_impl( 112 | enum_ident: &Ident, 113 | State { ident, fields, .. }: &State, 114 | AsyncState { 115 | new_inputs, 116 | output, 117 | new_expr, 118 | .. 119 | }: &AsyncState, 120 | ) -> TokenStream2 { 121 | let mut new_ident: Ident = Ident::new( 122 | &format!("new_{}", ident.to_string().to_snake_case()), 123 | ident.span(), 124 | ); 125 | new_ident.set_span(ident.span()); 126 | let params: Punctuated<_, Token![,]> = new_inputs.iter().collect(); 127 | let field_args: Punctuated<_, Token![,]> = fields 128 | .iter() 129 | .map(|StateField { ident, .. }| Ident::new(&format!("__new_result_{ident}"), ident.span())) 130 | .collect(); 131 | quote! { 132 | #[allow(clippy::too_many_arguments)] 133 | fn #new_ident(ui: &mut Ui, #params) -> Self { 134 | let (__future, #field_args) = #new_expr; 135 | #enum_ident::#ident( 136 | ui.ctx().spawn_async::<#output>(__future), 137 | #field_args 138 | ) 139 | } 140 | } 141 | } 142 | 143 | struct StatesEnum { 144 | visibility: Visibility, 145 | enum_token: Token![enum], 146 | ident: Ident, 147 | states: Vec, 148 | } 149 | 150 | impl Parse for StatesEnum { 151 | fn parse(input: ParseStream) -> syn::Result { 152 | let visibility: Visibility = input.parse()?; 153 | let enum_token: Token![enum] = input.parse()?; 154 | let ident: Ident = input.parse()?; 155 | input.parse::()?; 156 | let mut states = Vec::new(); 157 | while !input.is_empty() { 158 | states.push(input.parse()?); 159 | } 160 | Ok(StatesEnum { 161 | visibility, 162 | enum_token, 163 | ident, 164 | states, 165 | }) 166 | } 167 | } 168 | 169 | struct State { 170 | ident: Ident, 171 | fields: Punctuated, 172 | async_: Option, 173 | } 174 | 175 | impl Parse for State { 176 | fn parse(input: ParseStream) -> syn::Result { 177 | let is_async = input.peek(Token![async]); 178 | if is_async { 179 | input.parse::()?; 180 | } 181 | 182 | parse_custom_keyword(input, "state")?; 183 | 184 | let ident: Ident = input.parse()?; 185 | 186 | let fields_unparsed; 187 | parenthesized!(fields_unparsed in input); 188 | let fields: Punctuated = 189 | Punctuated::parse_terminated(&fields_unparsed)?; 190 | 191 | let async_: Option = if is_async { 192 | Some(input.parse()?) 193 | } else { 194 | input.parse::()?; 195 | None 196 | }; 197 | 198 | Ok(State { 199 | ident, 200 | fields, 201 | async_, 202 | }) 203 | } 204 | } 205 | 206 | struct StateField { 207 | ident: Ident, 208 | ty: Type, 209 | } 210 | 211 | impl Parse for StateField { 212 | fn parse(input: ParseStream) -> syn::Result { 213 | let ident: Ident = input.parse()?; 214 | input.parse::()?; 215 | let ty: Type = input.parse()?; 216 | Ok(StateField { ident, ty }) 217 | } 218 | } 219 | 220 | struct AsyncState { 221 | new_inputs: Punctuated, 222 | output: Type, 223 | new_expr: Expr, 224 | next_arms: Vec, 225 | } 226 | 227 | impl Parse for AsyncState { 228 | fn parse(input: ParseStream) -> syn::Result { 229 | input.parse::]>()?; 230 | 231 | let output: Type = input.parse()?; 232 | 233 | let async_block; 234 | braced!(async_block in input); 235 | 236 | parse_custom_keyword(&async_block, "new")?; 237 | let execute_inputs_unparsed; 238 | parenthesized!(execute_inputs_unparsed in &async_block); 239 | let new_inputs: Punctuated = 240 | Punctuated::parse_terminated(&execute_inputs_unparsed)?; 241 | let new_expr: Expr = async_block.parse()?; 242 | 243 | parse_custom_keyword(&async_block, "next")?; 244 | 245 | let next_arms_unparsed; 246 | braced!(next_arms_unparsed in &async_block); 247 | let mut next_arms: Vec = Vec::new(); 248 | while !next_arms_unparsed.is_empty() { 249 | next_arms.push(next_arms_unparsed.parse()?); 250 | } 251 | 252 | Ok(AsyncState { 253 | new_inputs, 254 | output, 255 | new_expr, 256 | next_arms, 257 | }) 258 | } 259 | } 260 | 261 | fn parse_custom_keyword(input: ParseStream, name: &str) -> syn::Result { 262 | let name_token: Ident = input.parse()?; 263 | if name_token != name { 264 | Err(Error::new(name_token.span(), format!("expected `{name}`"))) 265 | } else { 266 | Ok(name_token) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /crates/portal-wormhole/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "portal-wormhole" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | async-std = "1.12.0" 8 | dirs = "5.0.0" 9 | futures = "0.3.26" 10 | magic-wormhole = "0.7.4" 11 | single_value_channel = "1.2.2" 12 | tempfile = "3.3.0" 13 | thiserror = "2.0.9" 14 | oneshot = { version = "0.1.5", default-features = false, features = ["std"] } 15 | lazy_static = "1.4.0" 16 | zip = "2.1" 17 | walkdir = "2.3.2" 18 | url = "2.3.1" 19 | static_assertions = "1.1.0" 20 | tailcall = "1.0.1" 21 | log = { version = "0.4.19" } 22 | trait-set = "0.3.0" 23 | 24 | [lints] 25 | workspace = true 26 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/cancellation.rs: -------------------------------------------------------------------------------- 1 | //! A generalized solution for cancellation. 2 | //! This is not optimized at all, but it's very convenient to use. 3 | //! 4 | //! The main issue this tries to solve is provide a unified API for 5 | //! different forms of cancellation used in this project: 6 | //! 7 | //! * Cancellation using futures' [`AbortHandle`] 8 | //! * Cancellation in synchronous code. 9 | //! * Cancellation using a future (completion means cancellation). 10 | //! 11 | //! The API and implementation is inspired by .NET's `CancellationToken`: 12 | //! There's a cancellation token which is passed to cancelable functions and a cancellation source that controls cancellation. 13 | //! Consumers can register themselves with the cancellation token for cancellation. 14 | 15 | use futures::channel::oneshot; 16 | use futures::future::{AbortHandle, AbortRegistration}; 17 | use static_assertions::assert_impl_all; 18 | use std::future::Future; 19 | use std::sync::atomic::{AtomicBool, Ordering}; 20 | use std::sync::{Arc, RwLock}; 21 | use std::{fmt, mem}; 22 | use thiserror::Error; 23 | 24 | #[derive(Debug, Clone)] 25 | pub(crate) struct CancellationToken { 26 | inner: Arc, 27 | } 28 | 29 | impl CancellationToken { 30 | pub(crate) fn is_canceled(&self) -> bool { 31 | self.inner.canceled.load(Ordering::Relaxed) 32 | } 33 | 34 | pub(crate) fn error_if_canceled(&self) -> Result<(), CancellationError> { 35 | if self.is_canceled() { 36 | Err(CancellationError) 37 | } else { 38 | Ok(()) 39 | } 40 | } 41 | 42 | /// Registers a [`FnOnce`] to be called on cancellation. 43 | /// The func is called immediately if this token is already canceled. 44 | pub(crate) fn register(&self, func: impl FnOnce() + Send + Sync + 'static) { 45 | let mut funcs = self.inner.funcs.write().expect("lock poisoned"); 46 | 47 | if self.is_canceled() { 48 | func(); 49 | } else { 50 | funcs.push(Box::new(func)); 51 | } 52 | } 53 | } 54 | 55 | impl CancellationToken { 56 | pub(crate) fn as_abort_registration(&self) -> AbortRegistration { 57 | let (handle, registration) = AbortHandle::new_pair(); 58 | self.register(move || handle.abort()); 59 | registration 60 | } 61 | 62 | pub(crate) fn as_future(&self) -> impl Future { 63 | let (tx, rx) = oneshot::channel::<()>(); 64 | self.register(move || { 65 | _ = tx.send(()); 66 | }); 67 | async { _ = rx.await } 68 | } 69 | } 70 | 71 | #[derive(Debug, Clone, Default)] 72 | pub(crate) struct CancellationSource { 73 | inner: Arc, 74 | } 75 | 76 | impl CancellationSource { 77 | pub(crate) fn token(&self) -> CancellationToken { 78 | CancellationToken { 79 | inner: self.inner.clone(), 80 | } 81 | } 82 | 83 | pub(crate) fn cancel(&self) { 84 | for func in self.cancel_and_take_funcs() { 85 | func(); 86 | } 87 | } 88 | 89 | fn cancel_and_take_funcs(&self) -> Vec> { 90 | let mut funcs = self.inner.funcs.write().expect("lock poisoned"); 91 | self.inner.canceled.store(true, Ordering::Relaxed); 92 | mem::take(&mut *funcs) 93 | } 94 | } 95 | 96 | #[derive(Default)] 97 | struct CancellationInner { 98 | canceled: AtomicBool, 99 | funcs: RwLock>>, // TODO: This + Sync is needed for RwLock to be Sync, but can we work around that somehow? 100 | } 101 | 102 | impl fmt::Debug for CancellationInner { 103 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 | f.debug_struct("CancellationInner") 105 | .field("canceled", &self.canceled.load(Ordering::Relaxed)) 106 | .finish_non_exhaustive() 107 | } 108 | } 109 | 110 | #[derive(Error, Debug, Default)] 111 | #[error("")] 112 | pub(crate) struct CancellationError; 113 | 114 | assert_impl_all!(CancellationToken: Send); 115 | assert_impl_all!(CancellationSource: Send); 116 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::cancellation::CancellationError; 2 | use futures::stream::Aborted; 3 | use magic_wormhole::transfer::TransferError; 4 | use magic_wormhole::WormholeError; 5 | use thiserror::Error; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum PortalError { 9 | #[error(transparent)] 10 | Wormhole(#[from] WormholeError), 11 | #[error(transparent)] 12 | WormholeTransfer(TransferError), 13 | #[error("Transfer rejected by peer")] 14 | TransferRejected(TransferError), 15 | #[error(transparent)] 16 | Io(#[from] std::io::Error), 17 | #[error(transparent)] 18 | Walkdir(#[from] walkdir::Error), 19 | #[error(transparent)] 20 | Zip(#[from] zip::result::ZipError), 21 | #[error("The operation has been canceled")] 22 | Canceled, 23 | } 24 | 25 | const TRANSFER_REJECTED_MESSAGE: &str = "transfer rejected"; 26 | 27 | impl From for PortalError { 28 | fn from(value: TransferError) -> Self { 29 | match value { 30 | TransferError::PeerError(ref message) if message == TRANSFER_REJECTED_MESSAGE => { 31 | PortalError::TransferRejected(value) 32 | } 33 | _ => PortalError::WormholeTransfer(value), 34 | } 35 | } 36 | } 37 | 38 | impl From for PortalError { 39 | fn from(_: Aborted) -> Self { 40 | PortalError::Canceled 41 | } 42 | } 43 | 44 | impl From for PortalError { 45 | fn from(_: CancellationError) -> Self { 46 | PortalError::Canceled 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/fs.rs: -------------------------------------------------------------------------------- 1 | mod filename; 2 | pub(crate) use self::filename::*; 3 | mod persist; 4 | pub use self::persist::*; 5 | mod download; 6 | pub use self::download::*; 7 | mod path_parts; 8 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/fs/download.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(target_os = "windows"))] 2 | pub use self::generic::*; 3 | 4 | #[cfg(target_os = "windows")] 5 | pub use self::windows::*; 6 | 7 | #[cfg(not(target_os = "windows"))] 8 | mod generic { 9 | use std::path::Path; 10 | 11 | pub fn mark_as_downloaded(_path: &Path) {} 12 | } 13 | 14 | mod macos { 15 | //! On macOS the file is marked as quarantined, because we have 16 | //! `LSFileQuarantineEnabled` set to `true` in our app's `Info.plist`. 17 | //! 18 | //! See: 19 | } 20 | 21 | #[cfg(target_os = "windows")] 22 | mod windows { 23 | //! Internet Explorer introduced the concept of ["Security Zones"]. For our purposes, we 24 | //! just need to set the security zone to the "Internet" zone, which Windows will use to 25 | //! offer some protections. 26 | //! 27 | //! To do this, we write the [`Zone.Identifier`] NTFS alternative stream. 28 | //! 29 | //! Failure is intentionally ignored, since alternative stream are only 30 | //! supported by NTFS. 31 | //! 32 | //! ["Security Zones"]: https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms537183(v=vs.85) 33 | //! [`Zone.Identifier`]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/6e3f7352-d11c-4d76-8c39-2516a9df36e8 34 | use std::fs::OpenOptions; 35 | use std::io::{self, Write}; 36 | use std::path::Path; 37 | 38 | /// The value 3 corresponds with the Internet Zone. 39 | const ZONE_IDENTIFIER_CONTENTS: &str = "[ZoneTransfer]\r\nZoneId=3"; 40 | 41 | pub fn mark_as_downloaded(path: &Path) { 42 | _ = mark_as_downloaded_impl(path); 43 | } 44 | 45 | fn mark_as_downloaded_impl(path: &Path) -> io::Result<()> { 46 | let mut stream_path = path.to_owned(); 47 | stream_path.as_mut_os_string().push(":Zone.Identifier"); 48 | let mut file = OpenOptions::new() 49 | .write(true) 50 | .create(true) 51 | .open(stream_path)?; 52 | write!(file, "{ZONE_IDENTIFIER_CONTENTS}")?; 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/fs/filename.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::ops::Range; 3 | 4 | pub(crate) fn sanitize_file_name<'a>( 5 | file_name: impl Into>, 6 | replacement: &'a str, 7 | ) -> Cow<'a, str> { 8 | let mut file_name = file_name.into(); 9 | 10 | if file_name.is_empty() || file_name.chars().all(char::is_whitespace) { 11 | return replacement.into(); 12 | } 13 | 14 | replace_consecutive(&mut file_name, is_disallowed_char, replacement); 15 | 16 | if is_reserved_file_name(&file_name) { 17 | file_name.to_mut().insert_str(0, replacement); 18 | } 19 | 20 | file_name 21 | } 22 | 23 | #[cfg(windows)] 24 | fn is_disallowed_char(c: char) -> bool { 25 | // Source: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 26 | // Instead of just disallowing ASCII control characters, I opted to disallow all control characters. 27 | // Disallowing colon (:) is really important as allowing it could allow writing to NTFS alternate data streams. 28 | matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() 29 | } 30 | 31 | #[cfg(target_os = "macos")] 32 | fn is_disallowed_char(c: char) -> bool { 33 | // See: https://superuser.com/a/326627 34 | // macOS only disallows / but files containing colons (:) cannot be created in Finder. 35 | // Disallowing control characters just seems like a good idea to me. 36 | matches!(c, '/' | ':') || c.is_control() 37 | } 38 | 39 | #[cfg(not(any(windows, target_os = "macos")))] 40 | fn is_disallowed_char(c: char) -> bool { 41 | // Disallowing control characters just seems like a good idea to me. 42 | c == '/' || c.is_control() 43 | } 44 | 45 | // Source: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 46 | #[cfg(windows)] 47 | fn is_reserved_file_name(file_name: &str) -> bool { 48 | macro_rules! matches_ignore_case { 49 | ($target:ident, $($p:literal)|+) => { 50 | $($target.eq_ignore_ascii_case($p))||+ 51 | }; 52 | } 53 | 54 | matches_ignore_case!( 55 | file_name, 56 | "CON" 57 | | "PRN" 58 | | "AUX" 59 | | "NUL" 60 | | "COM0" 61 | | "COM1" 62 | | "COM2" 63 | | "COM3" 64 | | "COM4" 65 | | "COM5" 66 | | "COM6" 67 | | "COM7" 68 | | "COM8" 69 | | "COM9" 70 | | "LPT0" 71 | | "LPT1" 72 | | "LPT2" 73 | | "LPT3" 74 | | "LPT4" 75 | | "LPT5" 76 | | "LPT6" 77 | | "LPT7" 78 | | "LPT8" 79 | | "LPT9" 80 | ) 81 | } 82 | 83 | #[cfg(not(windows))] 84 | fn is_reserved_file_name(_file_name: &str) -> bool { 85 | false 86 | } 87 | 88 | // Replaces the given pattern with the replacement. 89 | // Consecutive matches are replaced once. 90 | fn replace_consecutive<'a>( 91 | haystack: &mut Cow<'a, str>, 92 | mut pattern: impl FnMut(char) -> bool, 93 | replacement: &'a str, 94 | ) { 95 | let mut index = 0; 96 | // TODO: use let chains once stabilized 97 | while index < haystack.len() { 98 | if let Some(range) = next_match(&haystack[index..], &mut pattern) { 99 | let absolute_range = (index + range.start)..(index + range.end); 100 | haystack.to_mut().replace_range(absolute_range, replacement); 101 | index += range.end; 102 | } else { 103 | break; 104 | } 105 | } 106 | } 107 | 108 | fn next_match(haystack: &str, mut pattern: impl FnMut(char) -> bool) -> Option> { 109 | let start = haystack.find(&mut pattern)?; 110 | 111 | let len: usize = haystack[start..] 112 | .chars() 113 | .take_while(|c| pattern(*c)) 114 | .map(|c| c.len_utf8()) 115 | .sum(); 116 | 117 | Some(start..(start + len)) 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | use super::*; 123 | 124 | const REPLACEMENT: &str = "_"; 125 | 126 | #[test] 127 | fn replaces_disallowed_chars() { 128 | assert_eq!( 129 | sanitize_file_name("/foo/bar/baz", REPLACEMENT), 130 | "_foo_bar_baz" 131 | ); 132 | assert_eq!( 133 | sanitize_file_name("foo/\0/\0/\0/bar", REPLACEMENT), 134 | "foo_bar" 135 | ); 136 | assert_eq!(sanitize_file_name("//////////////", REPLACEMENT), "_"); 137 | } 138 | 139 | #[test] 140 | fn ensures_filename_is_not_empty() { 141 | assert_eq!(sanitize_file_name("", REPLACEMENT), "_"); 142 | assert_eq!(sanitize_file_name(" ", REPLACEMENT), "_"); 143 | assert_eq!(sanitize_file_name("\t\r\n ", REPLACEMENT), "_"); 144 | } 145 | 146 | #[cfg(windows)] 147 | #[test] 148 | fn prefixes_reserved_file_names_with_replacement() { 149 | assert_eq!(sanitize_file_name("NUL", REPLACEMENT), "_NUL"); 150 | assert_eq!(sanitize_file_name("aux", REPLACEMENT), "_aux"); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/fs/path_parts.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::path::{Path, PathBuf}; 3 | 4 | pub(super) struct PathParts<'a> { 5 | path: &'a Path, 6 | stem: &'a OsStr, 7 | extension: Option<&'a OsStr>, 8 | } 9 | 10 | impl<'a> TryFrom<&'a Path> for PathParts<'a> { 11 | type Error = (); 12 | 13 | fn try_from(path: &'a Path) -> Result { 14 | let stem = path.file_stem().ok_or(())?; 15 | let extension = path.extension(); 16 | Ok(PathParts { 17 | path, 18 | stem, 19 | extension, 20 | }) 21 | } 22 | } 23 | 24 | impl PathParts<'_> { 25 | pub(crate) fn to_path_with_counter(&self, counter: u64) -> PathBuf { 26 | if counter == 0 { 27 | self.path.to_owned() 28 | } else { 29 | let mut file_name = self.stem.to_owned(); 30 | file_name.push(format!(" ({counter})")); 31 | 32 | if let Some(extension) = self.extension { 33 | file_name.push("."); 34 | file_name.push(extension); 35 | } 36 | 37 | self.path.with_file_name(file_name) 38 | } 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::*; 45 | 46 | #[test] 47 | fn formats_path_with_counter() { 48 | for (expected, input) in [ 49 | ("foo (1).bar", "foo.bar"), 50 | ("foo (1)", "foo"), 51 | (".bar (1)", ".bar"), 52 | ] { 53 | assert_eq!( 54 | expected, 55 | PathParts::try_from(Path::new(input)) 56 | .expect("input to be a valid path") 57 | .to_path_with_counter(1) 58 | .to_str() 59 | .expect("path to be valid unicode") 60 | ); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/fs/persist.rs: -------------------------------------------------------------------------------- 1 | use super::path_parts::PathParts; 2 | use std::io::{self, ErrorKind}; 3 | use std::path::Path; 4 | use tailcall::tailcall; 5 | 6 | pub fn open_with_conflict_resolution( 7 | path: &Path, 8 | opener: impl FnMut(&Path) -> io::Result, 9 | ) -> io::Result { 10 | return open(PathParts::try_from(path).expect("Invalid path"), opener, 0); 11 | 12 | #[tailcall] 13 | fn open( 14 | path_parts: PathParts, 15 | mut opener: impl FnMut(&Path) -> io::Result, 16 | counter: u64, 17 | ) -> io::Result { 18 | let path = path_parts.to_path_with_counter(counter); 19 | match opener(&path) { 20 | Err(error) if error.kind() == ErrorKind::AlreadyExists => open( 21 | path_parts, 22 | opener, 23 | counter.checked_add(1).expect("Counter overflow"), 24 | ), 25 | result => result, 26 | } 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | use std::collections::VecDeque; 34 | use std::path::PathBuf; 35 | use thiserror::Error; 36 | 37 | #[test] 38 | fn uses_original_file_name_when_possible() { 39 | let expected_path = PathBuf::from("bar/foo.txt"); 40 | let path = open_with_conflict_resolution(&expected_path, open(VecDeque::default())) 41 | .expect("open to succeed"); 42 | assert_eq!(path, expected_path); 43 | } 44 | 45 | #[test] 46 | fn retries_on_conflict() { 47 | let first_path = PathBuf::from("bar/foo.txt"); 48 | let existing_paths = vec![ 49 | first_path.clone(), 50 | PathBuf::from("bar/foo (1).txt"), 51 | PathBuf::from("bar/foo (2).txt"), 52 | PathBuf::from("bar/foo (3).txt"), 53 | ]; 54 | let expected_path = PathBuf::from("bar/foo (4).txt"); 55 | let path = open_with_conflict_resolution(&first_path, open(existing_paths.into())) 56 | .expect("open to succeed"); 57 | assert_eq!(path, expected_path); 58 | } 59 | 60 | fn open(mut existing_paths: VecDeque) -> impl FnMut(&Path) -> io::Result { 61 | move |path| match existing_paths.pop_front() { 62 | None => Ok(path.to_owned()), 63 | Some(expected_path) => { 64 | assert_eq!(path, expected_path); 65 | Err(io::Error::new(ErrorKind::AlreadyExists, UnitError)) 66 | } 67 | } 68 | } 69 | 70 | #[derive(Error, Debug)] 71 | #[error("")] 72 | struct UnitError; 73 | } 74 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | pub mod receive; 3 | pub use self::error::*; 4 | mod cancellation; 5 | mod fs; 6 | pub mod send; 7 | mod sync; 8 | mod temp_zip; 9 | mod transit; 10 | 11 | pub use magic_wormhole::transit::{ConnectionType, TransitInfo}; 12 | pub use magic_wormhole::uri::WormholeTransferUri; 13 | pub use magic_wormhole::Code; 14 | use std::fmt; 15 | use trait_set::trait_set; 16 | use url::Url; 17 | 18 | trait_set! { 19 | pub trait RequestRepaint = FnMut() + Clone + Send + Sync + 'static; 20 | } 21 | 22 | #[derive(Default, Copy, Clone)] 23 | pub struct Progress { 24 | pub value: u64, 25 | pub total: u64, 26 | } 27 | 28 | #[non_exhaustive] 29 | pub struct SharableWormholeTransferUri { 30 | pub code: Code, 31 | } 32 | 33 | impl SharableWormholeTransferUri { 34 | pub fn new(code: Code) -> Self { 35 | Self { code } 36 | } 37 | } 38 | 39 | impl From<&SharableWormholeTransferUri> for Url { 40 | fn from(value: &SharableWormholeTransferUri) -> Self { 41 | let mut url = 42 | Url::parse("https://wormhole-transfer.link").expect("constant URL should be valid"); 43 | url.set_fragment(Some(&value.code)); 44 | url 45 | } 46 | } 47 | 48 | impl fmt::Display for SharableWormholeTransferUri { 49 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 50 | let uri: Url = self.into(); 51 | write!(f, "{}", uri) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/receive.rs: -------------------------------------------------------------------------------- 1 | use crate::cancellation::{CancellationSource, CancellationToken}; 2 | use crate::error::PortalError; 3 | use crate::fs::{mark_as_downloaded, open_with_conflict_resolution, sanitize_file_name}; 4 | use crate::sync::BorrowingOneshotReceiver; 5 | use crate::transit::{ 6 | progress_handler, transit_handler, ProgressHandler, TransitHandler, RELAY_HINTS, 7 | }; 8 | use crate::{Progress, RequestRepaint}; 9 | use async_std::fs::File; 10 | use futures::channel::oneshot; 11 | use futures::future::Abortable; 12 | use futures::Future; 13 | use magic_wormhole::transfer::{self, ReceiveRequest}; 14 | use magic_wormhole::transit::{Abilities, TransitInfo}; 15 | use magic_wormhole::{Code, MailboxConnection, Wormhole}; 16 | use single_value_channel as svc; 17 | use std::fs::{self, OpenOptions}; 18 | use std::mem; 19 | use std::path::PathBuf; 20 | 21 | pub type ConnectResult = Result; 22 | pub type ReceiveResult = Result; 23 | 24 | pub fn connect(code: Code) -> (impl Future, ConnectingController) { 25 | let cancellation_source = CancellationSource::default(); 26 | let cancellation_token = cancellation_source.token(); 27 | let controller = ConnectingController { 28 | cancellation_source, 29 | }; 30 | (connect_impl(code, cancellation_token), controller) 31 | } 32 | 33 | pub struct ConnectingController { 34 | cancellation_source: CancellationSource, 35 | } 36 | 37 | impl ConnectingController { 38 | pub fn cancel(&mut self) { 39 | self.cancellation_source.cancel() 40 | } 41 | } 42 | 43 | async fn connect_impl(code: Code, cancellation: CancellationToken) -> ConnectResult { 44 | const ALLOCATE_NAMEPLATE_IF_MISSING: bool = false; 45 | let mailbox = Abortable::new( 46 | MailboxConnection::connect(transfer::APP_CONFIG, code, ALLOCATE_NAMEPLATE_IF_MISSING), 47 | cancellation.as_abort_registration(), 48 | ) 49 | .await??; 50 | let wormhole = Abortable::new( 51 | Wormhole::connect(mailbox), 52 | cancellation.as_abort_registration(), 53 | ) 54 | .await??; 55 | 56 | transfer::request_file( 57 | wormhole, 58 | RELAY_HINTS.clone(), 59 | Abilities::ALL, 60 | cancellation.as_future(), 61 | ) 62 | .await? 63 | .ok_or(PortalError::Canceled) 64 | .map(|receive_request| ReceiveRequestController { receive_request }) 65 | } 66 | 67 | pub struct ReceiveRequestController { 68 | receive_request: ReceiveRequest, 69 | } 70 | 71 | impl ReceiveRequestController { 72 | pub fn file_name(&self) -> String { 73 | self.receive_request.file_name() 74 | } 75 | 76 | pub fn filesize(&self) -> u64 { 77 | self.receive_request.file_size() 78 | } 79 | 80 | pub fn accept( 81 | self, 82 | request_repaint: impl RequestRepaint, 83 | ) -> (impl Future, ReceivingController) { 84 | ReceivingController::new(self.receive_request, request_repaint) 85 | } 86 | 87 | pub async fn reject(self) -> Result<(), PortalError> { 88 | Ok(self.receive_request.reject().await?) 89 | } 90 | } 91 | 92 | pub struct ReceivingController { 93 | transit_info_receiver: BorrowingOneshotReceiver, 94 | progress: svc::Receiver, 95 | cancel_sender: Option>, 96 | } 97 | 98 | impl ReceivingController { 99 | fn new( 100 | receive_request: ReceiveRequest, 101 | request_repaint: impl RequestRepaint, 102 | ) -> (impl Future, Self) { 103 | let (transit_info_sender, transit_info_receiver) = ::oneshot::channel(); 104 | let (progress, progress_updater) = svc::channel_starting_with(Progress::default()); 105 | let (cancel_sender, cancel_receiver) = oneshot::channel(); 106 | let controller = ReceivingController { 107 | transit_info_receiver: transit_info_receiver.into(), 108 | progress, 109 | cancel_sender: Some(cancel_sender), 110 | }; 111 | let future = accept( 112 | receive_request, 113 | transit_handler(transit_info_sender, request_repaint.clone()), 114 | progress_handler(progress_updater, request_repaint), 115 | cancel_receiver, 116 | ); 117 | (future, controller) 118 | } 119 | 120 | pub fn transit_info(&mut self) -> Option<&TransitInfo> { 121 | self.transit_info_receiver.value() 122 | } 123 | 124 | pub fn progress(&mut self) -> &Progress { 125 | self.progress.latest() 126 | } 127 | 128 | pub fn cancel(&mut self) { 129 | self.cancel_sender.take().map(|c| c.send(())); 130 | } 131 | } 132 | 133 | async fn accept( 134 | receive_request: ReceiveRequest, 135 | transit_handler: impl TransitHandler, 136 | progress_handler: impl ProgressHandler, 137 | cancel: oneshot::Receiver<()>, 138 | ) -> ReceiveResult { 139 | let untrusted_filename = receive_request.file_name(); 140 | let base_path = { 141 | let mut path = dirs::download_dir().expect("Unable to detect downloads directory"); 142 | path.push(sanitize_file_name(untrusted_filename, "_").as_ref()); 143 | path 144 | }; 145 | let (file, file_path) = open_with_conflict_resolution(&base_path, |path| { 146 | OpenOptions::new() 147 | .create_new(true) 148 | .write(true) 149 | .open(path) 150 | .map(|f| (f, path.to_owned())) 151 | })?; 152 | let mut async_file = File::from(file); 153 | 154 | let mut canceled = false; 155 | receive_request 156 | .accept(transit_handler, progress_handler, &mut async_file, async { 157 | _ = cancel.await; 158 | canceled = true; 159 | }) 160 | .await?; 161 | 162 | if canceled { 163 | mem::drop(async_file); 164 | fs::remove_file(file_path)?; 165 | return Err(PortalError::Canceled); 166 | } 167 | 168 | mark_as_downloaded(&file_path); 169 | 170 | Ok(file_path) 171 | } 172 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/send.rs: -------------------------------------------------------------------------------- 1 | use self::sendable_file::SendableFile; 2 | use crate::cancellation::{CancellationSource, CancellationToken}; 3 | use crate::error::PortalError; 4 | use crate::transit::{ProgressHandler, TransitHandler, RELAY_HINTS}; 5 | use crate::{Progress, RequestRepaint}; 6 | use async_std::fs::File; 7 | use futures::future::{Abortable, BoxFuture}; 8 | use futures::Future; 9 | use log::warn; 10 | use magic_wormhole::transit::{Abilities, TransitInfo}; 11 | use magic_wormhole::{transfer, Code, MailboxConnection, Wormhole}; 12 | use single_value_channel as svc; 13 | use std::sync::Arc; 14 | use trait_set::trait_set; 15 | 16 | mod request; 17 | pub use self::request::{CachedSendRequest, SendRequest}; 18 | mod sendable_file; 19 | 20 | pub fn send( 21 | send_request: SendRequest, 22 | request_repaint: impl RequestRepaint, 23 | ) -> ( 24 | impl Future>, 25 | SendingController, 26 | ) { 27 | let (progress_receiver, progress_updater) = 28 | svc::channel_starting_with(SendingProgress::Connecting); 29 | 30 | let cancellation_source = CancellationSource::default(); 31 | let cancellation_token = cancellation_source.token(); 32 | let controller = SendingController { 33 | progress_receiver, 34 | cancellation_source, 35 | }; 36 | 37 | let future = send_impl( 38 | send_request, 39 | report(progress_updater, request_repaint), 40 | cancellation_token, 41 | ); 42 | 43 | (future, controller) 44 | } 45 | 46 | pub struct SendingController { 47 | progress_receiver: svc::Receiver, 48 | cancellation_source: CancellationSource, 49 | } 50 | 51 | pub enum SendingProgress { 52 | Packing, 53 | Connecting, 54 | Connected(Code), 55 | PreparingToSend, 56 | Sending(Arc, Progress), 57 | } 58 | 59 | impl SendingController { 60 | pub fn progress(&mut self) -> &SendingProgress { 61 | self.progress_receiver.latest() 62 | } 63 | 64 | pub fn cancel(&mut self) { 65 | self.cancellation_source.cancel() 66 | } 67 | } 68 | 69 | async fn send_impl( 70 | send_request: SendRequest, 71 | mut report: impl Reporter, 72 | cancellation: CancellationToken, 73 | ) -> Result<(), (PortalError, SendRequest)> { 74 | report(SendingProgress::Packing); 75 | let sendable_file = Abortable::new( 76 | SendableFile::from_send_request(send_request.clone(), cancellation.clone()), 77 | cancellation.as_abort_registration(), 78 | ) 79 | .await 80 | .with_send_request(send_request.clone())? 81 | .with_send_request(send_request.clone())?; 82 | send_impl_with_sendable_file(&sendable_file, report, cancellation) 83 | .await 84 | .with_send_request(SendRequest::new_cached(sendable_file, send_request)) 85 | } 86 | 87 | async fn send_impl_with_sendable_file( 88 | sendable_file: &SendableFile, 89 | mut report: impl Reporter, 90 | cancellation: CancellationToken, 91 | ) -> Result<(), PortalError> { 92 | let (transit_info_receiver, transit_info_updater) = svc::channel(); 93 | 94 | report(SendingProgress::Connecting); 95 | let wormhole = async { 96 | let (code, wormhole_future) = connect().await?; 97 | report(SendingProgress::Connected(code)); 98 | 99 | let wormhole = wormhole_future.await?; 100 | report(SendingProgress::PreparingToSend); 101 | 102 | Result::<_, PortalError>::Ok(wormhole) 103 | }; 104 | 105 | let wormhole = Abortable::new(wormhole, cancellation.as_abort_registration()).await??; 106 | 107 | send_file( 108 | wormhole, 109 | sendable_file, 110 | progress_handler(transit_info_receiver, report.clone()), 111 | transit_handler(transit_info_updater, report), 112 | cancellation.as_future(), 113 | ) 114 | .await 115 | } 116 | 117 | trait_set! { 118 | trait Reporter = FnMut(SendingProgress) + Clone + 'static; 119 | } 120 | 121 | fn report( 122 | updater: svc::Updater, 123 | mut request_repaint: impl RequestRepaint, 124 | ) -> impl Reporter { 125 | move |progress| { 126 | _ = updater.update(progress); 127 | request_repaint(); 128 | } 129 | } 130 | 131 | fn transit_handler( 132 | updater: svc::Updater>>, 133 | mut report: impl Reporter, 134 | ) -> impl TransitHandler { 135 | move |transit_info| { 136 | let transit_info = Arc::new(transit_info); 137 | _ = updater.update(Some(Arc::clone(&transit_info))); 138 | report(SendingProgress::Sending(transit_info, Progress::default())); 139 | } 140 | } 141 | 142 | fn progress_handler( 143 | mut transit_info: svc::Receiver>>, 144 | mut report: impl Reporter, 145 | ) -> impl ProgressHandler { 146 | move |value, total| match transit_info.latest().clone() { 147 | None => warn!("transit info unexpectedly missing in progress handler"), 148 | Some(transit_info) => report(SendingProgress::Sending( 149 | transit_info, 150 | Progress { value, total }, 151 | )), 152 | } 153 | } 154 | 155 | async fn send_file( 156 | wormhole: Wormhole, 157 | sendable_file: &SendableFile, 158 | progress_handler: impl ProgressHandler, 159 | transit_handler: impl TransitHandler, 160 | cancel: impl Future, 161 | ) -> Result<(), PortalError> { 162 | let mut file = File::open(sendable_file.path()).await?; 163 | let metadata = file.metadata().await?; 164 | let file_size = metadata.len(); 165 | 166 | let mut canceled = false; 167 | transfer::send_file( 168 | wormhole, 169 | RELAY_HINTS.clone(), 170 | &mut file, 171 | sendable_file.file_name().to_string_lossy(), 172 | file_size, 173 | Abilities::ALL, 174 | transit_handler, 175 | progress_handler, 176 | async { 177 | cancel.await; 178 | canceled = true; 179 | }, 180 | ) 181 | .await?; 182 | 183 | if canceled { 184 | Err(PortalError::Canceled) 185 | } else { 186 | Ok(()) 187 | } 188 | } 189 | 190 | async fn connect() -> Result<(Code, BoxFuture<'static, Result>), PortalError> 191 | { 192 | let mailbox = MailboxConnection::create(transfer::APP_CONFIG, 4).await?; 193 | let code = mailbox.code().clone(); 194 | let future = Wormhole::connect(mailbox); 195 | Ok((code, Box::pin(async { Ok(future.await?) }))) 196 | } 197 | 198 | trait ResultExt { 199 | fn with_send_request(self, send_request: SendRequest) -> Result; 200 | } 201 | 202 | impl ResultExt for Result 203 | where 204 | E: Into, 205 | { 206 | fn with_send_request(self, send_request: SendRequest) -> Result { 207 | self.map_err(|error| (error.into(), send_request)) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/send/request.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::Arc; 3 | 4 | use super::sendable_file::SendableFile; 5 | 6 | #[derive(Clone, Debug)] 7 | pub enum SendRequest { 8 | File(PathBuf), 9 | Folder(PathBuf), 10 | Selection(Vec), 11 | Cached(Box, CachedSendRequest), 12 | } 13 | 14 | impl SendRequest { 15 | pub(crate) fn new_cached( 16 | sendable_file: Arc, 17 | original_request: SendRequest, 18 | ) -> SendRequest { 19 | SendRequest::Cached(original_request.flatten(), CachedSendRequest(sendable_file)) 20 | } 21 | 22 | fn flatten(self) -> Box { 23 | match self { 24 | SendRequest::Cached(inner_request, _) => inner_request, 25 | _ => Box::new(self), 26 | } 27 | } 28 | } 29 | 30 | #[derive(Clone, Debug)] 31 | pub struct CachedSendRequest(pub(crate) Arc); 32 | 33 | impl SendRequest { 34 | pub fn from_paths(paths: Vec) -> Option { 35 | match paths.len() { 36 | 0 => None, 37 | 1 if paths[0].is_dir() => Some(SendRequest::Folder(paths[0].clone())), 38 | 1 => Some(SendRequest::File(paths[0].clone())), 39 | _ => Some(SendRequest::Selection(paths)), 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/send/sendable_file.rs: -------------------------------------------------------------------------------- 1 | use super::SendRequest; 2 | use crate::cancellation::CancellationToken; 3 | use crate::temp_zip::{pack_folder_as_zip, pack_selection_as_zip}; 4 | use crate::PortalError; 5 | use async_std::task::spawn_blocking; 6 | use std::ffi::{OsStr, OsString}; 7 | use std::path::{Path, PathBuf}; 8 | use std::sync::Arc; 9 | use tempfile::NamedTempFile; 10 | 11 | #[derive(Debug)] 12 | pub(crate) enum SendableFile { 13 | Path(PathBuf), 14 | Temporary(OsString, NamedTempFile), 15 | } 16 | 17 | impl SendableFile { 18 | /// Note that cancelling this future may not cancel the background work 19 | /// immediately as the packing functions only accept cancellation in between files. 20 | pub(crate) async fn from_send_request( 21 | send_request: SendRequest, 22 | cancellation: CancellationToken, 23 | ) -> Result, PortalError> { 24 | match send_request { 25 | SendRequest::Cached(_, cached) => Ok(cached.0), 26 | SendRequest::File(file_path) => Ok(Arc::new(SendableFile::Path(file_path))), 27 | SendRequest::Folder(folder_path) => Ok(Arc::new(SendableFile::Temporary( 28 | folder_zip_file_name(&folder_path), 29 | spawn_blocking(move || pack_folder_as_zip(&folder_path, cancellation)).await?, 30 | ))), 31 | SendRequest::Selection(paths) => Ok(Arc::new(SendableFile::Temporary( 32 | selection_zip_file_name(&paths), 33 | spawn_blocking(move || pack_selection_as_zip(&paths, cancellation)).await?, 34 | ))), 35 | } 36 | } 37 | 38 | pub(crate) fn path(&self) -> &Path { 39 | match self { 40 | SendableFile::Path(path) => path, 41 | SendableFile::Temporary(_, file) => file.path(), 42 | } 43 | } 44 | 45 | pub(crate) fn file_name(&self) -> &OsStr { 46 | match self { 47 | SendableFile::Path(path) => path.file_name().expect("path should be absolute"), 48 | SendableFile::Temporary(file_name, _) => file_name, 49 | } 50 | } 51 | } 52 | 53 | fn folder_zip_file_name(folder_path: &Path) -> OsString { 54 | folder_path 55 | .file_name() 56 | .map(|p| concat_os_strs(p, ".zip")) 57 | .unwrap_or_else(|| OsString::from("Folder.zip")) 58 | } 59 | 60 | fn selection_zip_file_name(paths: &[PathBuf]) -> OsString { 61 | common_parent_directory(paths) 62 | .and_then(|p| p.file_name()) 63 | .map(|p| concat_os_strs(p, ".zip")) 64 | .unwrap_or_else(|| OsString::from("Selection.zip")) 65 | } 66 | 67 | fn concat_os_strs(a: impl AsRef, b: impl AsRef) -> OsString { 68 | let a = a.as_ref(); 69 | let b = b.as_ref(); 70 | let mut result = OsString::with_capacity(a.len() + b.len()); 71 | result.push(a); 72 | result.push(b); 73 | result 74 | } 75 | 76 | fn common_parent_directory(paths: &[PathBuf]) -> Option<&Path> { 77 | let parent = paths.first()?.parent()?; 78 | paths 79 | .iter() 80 | .skip(1) 81 | .all(|p| p.parent() == Some(parent)) 82 | .then_some(parent) 83 | } 84 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/sync.rs: -------------------------------------------------------------------------------- 1 | use oneshot::TryRecvError; 2 | use BorrowingOneshotReceiverState::*; 3 | 4 | pub struct BorrowingOneshotReceiver { 5 | state: BorrowingOneshotReceiverState, 6 | incoming: oneshot::Receiver, 7 | } 8 | 9 | enum BorrowingOneshotReceiverState { 10 | Waiting, 11 | Completed(T), 12 | Disconnected, 13 | } 14 | 15 | impl BorrowingOneshotReceiverState { 16 | fn value(&self) -> Option<&T> { 17 | match self { 18 | Completed(ref value) => Some(value), 19 | _ => None, 20 | } 21 | } 22 | } 23 | 24 | impl BorrowingOneshotReceiver { 25 | pub fn value(&mut self) -> Option<&T> { 26 | self.try_recv(); 27 | self.state.value() 28 | } 29 | 30 | fn try_recv(&mut self) { 31 | if matches!(self.state, Waiting) { 32 | self.state = match self.incoming.try_recv() { 33 | Ok(value) => Completed(value), 34 | Err(TryRecvError::Empty) => Waiting, 35 | Err(TryRecvError::Disconnected) => Disconnected, 36 | } 37 | } 38 | } 39 | } 40 | 41 | impl From> for BorrowingOneshotReceiver { 42 | fn from(value: oneshot::Receiver) -> Self { 43 | BorrowingOneshotReceiver { 44 | state: BorrowingOneshotReceiverState::Waiting, 45 | incoming: value, 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/temp_zip.rs: -------------------------------------------------------------------------------- 1 | use crate::cancellation::CancellationToken; 2 | use crate::PortalError; 3 | use std::borrow::Cow; 4 | use std::fs::{self, File}; 5 | use std::io::{self, Seek, Write}; 6 | use std::path::{Path, PathBuf}; 7 | use tempfile::NamedTempFile; 8 | use walkdir::WalkDir; 9 | use zip::write::{FileOptions, SimpleFileOptions}; 10 | use zip::ZipWriter; 11 | 12 | /// Packs a folder as a Zip file recursively. 13 | pub(crate) fn pack_folder_as_zip( 14 | folder_path: &Path, 15 | cancellation: CancellationToken, 16 | ) -> Result { 17 | cancellation.error_if_canceled()?; 18 | 19 | let mut temp_file = NamedTempFile::new()?; 20 | { 21 | let mut writer = ZipWriter::new(&mut temp_file); 22 | add_folder_to_zip(folder_path, None, &mut writer, cancellation)?; 23 | } 24 | Ok(temp_file) 25 | } 26 | 27 | /// Packs a selection of paths (e.g. from drag and drop) as a Zip file. 28 | /// 29 | /// Note that this function does not handle duplicate entries as this should be a rare case 30 | /// (it requires selecting files across multiple directories). 31 | pub(crate) fn pack_selection_as_zip( 32 | paths: &[PathBuf], 33 | cancellation: CancellationToken, 34 | ) -> Result { 35 | cancellation.error_if_canceled()?; 36 | 37 | let mut temp_file = NamedTempFile::new()?; 38 | { 39 | let mut writer = ZipWriter::new(&mut temp_file); 40 | for path in paths { 41 | add_path_to_zip(path, None, &mut writer, cancellation.clone())?; 42 | } 43 | } 44 | Ok(temp_file) 45 | } 46 | 47 | /// Adds a file or folder to the Zip file. 48 | /// 49 | /// Symbolic links are materialized (i.e. resolved and the real files or folders are added to the Zip file). 50 | fn add_path_to_zip( 51 | path: &Path, 52 | relative_path: Option<&Path>, 53 | writer: &mut ZipWriter, 54 | cancellation: CancellationToken, 55 | ) -> Result<(), PortalError> 56 | where 57 | W: Write + Seek, 58 | { 59 | cancellation.error_if_canceled()?; 60 | 61 | let relative_path = relative_path 62 | .unwrap_or_else(|| Path::new(path.file_name().expect("path should be absolute"))); 63 | 64 | if path.is_dir() { 65 | add_folder_to_zip(path, Some(relative_path), writer, cancellation)?; 66 | } else if path.is_file() { 67 | add_file_to_zip(path, relative_path, writer)?; 68 | } else if path.is_symlink() { 69 | add_path_to_zip( 70 | &std::fs::read_link(path)?, 71 | Some(relative_path), 72 | writer, 73 | cancellation, 74 | )?; 75 | } else { 76 | unreachable!("Path is either a file, a directory or a symlink"); 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | /// Adds a folder to the Zip file by recursively walking through the directory. 83 | /// 84 | /// Symbolic links are materialized (i.e. resolved and the real files are added to the Zip file). \ 85 | /// This is the default behaviour of the `zip` tool and best for cross-platform compatibility. 86 | fn add_folder_to_zip( 87 | folder_path: &Path, 88 | folder_relative_path: Option<&Path>, 89 | writer: &mut ZipWriter, 90 | cancellation: CancellationToken, 91 | ) -> Result<(), PortalError> 92 | where 93 | W: Write + Seek, 94 | { 95 | cancellation.error_if_canceled()?; 96 | 97 | for entry in WalkDir::new(folder_path).follow_links(true) { 98 | cancellation.error_if_canceled()?; 99 | 100 | let entry = entry?; 101 | let relative_path = 102 | relative_path_for_entry_in_folder(folder_path, folder_relative_path, entry.path()); 103 | let relative_path_as_string = relative_path.to_string_lossy(); 104 | 105 | if entry.file_type().is_dir() { 106 | writer.add_directory(relative_path_as_string, SimpleFileOptions::default())?; 107 | } else if entry.file_type().is_file() { 108 | add_file_to_zip(entry.path(), &relative_path, writer)?; 109 | } else { 110 | unreachable!("The file is either a file or directory. Symlinks have been be materialized by .follow_links(true)"); 111 | } 112 | } 113 | 114 | Ok(()) 115 | } 116 | 117 | fn relative_path_for_entry_in_folder<'a>( 118 | folder_path: &'a Path, 119 | folder_relative_path: Option<&'a Path>, 120 | entry_path: &'a Path, 121 | ) -> Cow<'a, Path> { 122 | let relative_path = entry_path 123 | .strip_prefix(folder_path) 124 | .expect("File in folder should start with folder path"); 125 | match folder_relative_path { 126 | None => Cow::Borrowed(relative_path), 127 | Some(folder_relative_path) => { 128 | let mut combined_path = PathBuf::new(); 129 | combined_path.push(folder_relative_path); 130 | combined_path.push(relative_path); 131 | Cow::Owned(combined_path) 132 | } 133 | } 134 | } 135 | 136 | fn add_file_to_zip( 137 | source_path: &Path, 138 | relative_path: &Path, 139 | writer: &mut ZipWriter, 140 | ) -> Result<(), PortalError> 141 | where 142 | W: Write + Seek, 143 | { 144 | writer.start_file(relative_path.to_string_lossy(), file_options(source_path)?)?; 145 | let mut reader = File::open(source_path)?; 146 | io::copy(&mut reader, writer)?; 147 | Ok(()) 148 | } 149 | 150 | fn file_options(path: &Path) -> Result { 151 | // Files >= 4 GiB require large_file 152 | let file_size = fs::metadata(path)?.len(); 153 | let large_file = file_size > u32::MAX as u64; 154 | 155 | Ok(FileOptions::default().large_file(large_file)) 156 | } 157 | -------------------------------------------------------------------------------- /crates/portal-wormhole/src/transit.rs: -------------------------------------------------------------------------------- 1 | use crate::{Progress, RequestRepaint}; 2 | use lazy_static::lazy_static; 3 | use magic_wormhole::transit::{RelayHint, TransitInfo, DEFAULT_RELAY_SERVER}; 4 | use single_value_channel as svc; 5 | use url::Url; 6 | 7 | lazy_static! { 8 | pub static ref RELAY_HINTS: Vec = relay_hints(); 9 | } 10 | 11 | pub trait TransitHandler: FnOnce(TransitInfo) {} 12 | 13 | impl TransitHandler for F where F: FnOnce(TransitInfo) {} 14 | 15 | pub trait ProgressHandler: FnMut(u64, u64) + 'static {} 16 | 17 | impl ProgressHandler for F where F: FnMut(u64, u64) + 'static {} 18 | 19 | pub fn transit_handler( 20 | sender: ::oneshot::Sender, 21 | mut request_repaint: impl RequestRepaint, 22 | ) -> impl TransitHandler { 23 | move |transit_info| { 24 | _ = sender.send(transit_info); 25 | request_repaint(); 26 | } 27 | } 28 | 29 | pub fn progress_handler( 30 | updater: svc::Updater, 31 | mut request_repaint: impl RequestRepaint, 32 | ) -> impl ProgressHandler { 33 | move |value, total| { 34 | _ = updater.update(Progress { value, total }); 35 | request_repaint(); 36 | } 37 | } 38 | 39 | fn relay_hints() -> Vec { 40 | let hint = RelayHint::from_urls(None, [default_relay_server()]) 41 | .expect("constant relay hints should be valid"); 42 | vec![hint] 43 | } 44 | 45 | fn default_relay_server() -> Url { 46 | DEFAULT_RELAY_SERVER 47 | .parse() 48 | .expect("constant URL should be valid") 49 | } 50 | -------------------------------------------------------------------------------- /license-apache.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /license-mit.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tau Gärtli [https://tau.garden] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Portal 2 | 3 | A cross-platform GUI for [Magic Wormhole](https://github.com/magic-wormhole/magic-wormhole) 4 | implemented using the amazing [egui](https://www.egui.rs/) library. 5 | 6 | ## Installation 7 | 8 | Head over to [Releases](https://github.com/bash/portal/releases/latest) 9 | to download pre-built executables (there's even a Windows installer). 10 | 11 | ## Screenshots 12 | 13 | 14 | 15 | 16 | A screenshot of the Portal app in send mode 17 | 18 | 19 | 20 | 21 | A screenshot of the Portal app in receive mode 22 | 23 | 24 | ## License 25 | 26 | Licensed under either of 27 | 28 | * Apache License, Version 2.0 29 | ([license-apache.txt](license-apache.txt) or http://www.apache.org/licenses/LICENSE-2.0) 30 | * MIT license 31 | ([license-mit.txt](license-mit.txt) or http://opensource.org/licenses/MIT) 32 | 33 | at your option. 34 | 35 | ## Contribution 36 | 37 | Unless you explicitly state otherwise, any contribution intentionally submitted 38 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 39 | dual licensed as above, without any additional terms or conditions. 40 | -------------------------------------------------------------------------------- /screenshots/receive-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/screenshots/receive-dark.png -------------------------------------------------------------------------------- /screenshots/receive-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/screenshots/receive-light.png -------------------------------------------------------------------------------- /screenshots/send-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/screenshots/send-dark.png -------------------------------------------------------------------------------- /screenshots/send-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/portal/ded976244523cd2e68a362139e1371a78bf46808/screenshots/send-light.png -------------------------------------------------------------------------------- /src/auto_viewport_theme.rs: -------------------------------------------------------------------------------- 1 | //! An egui plugin that syncs egui's current theme to the viewport's. 2 | 3 | use egui::{Context, Id, SystemTheme, ThemePreference, ViewportCommand}; 4 | use std::sync::Arc; 5 | 6 | // We use Id::NULL because there's only one instance of this plugin. 7 | const PLUGIN_ID: Id = Id::NULL; 8 | 9 | pub(crate) fn register(ctx: &Context) { 10 | if ctx.data(|d| d.get_temp::(PLUGIN_ID).is_none()) { 11 | ctx.on_end_pass("update_viewport_theme", Arc::new(State::end_pass)); 12 | } 13 | } 14 | 15 | #[derive(Debug, Clone)] 16 | struct State(ThemePreference); 17 | 18 | impl State { 19 | fn end_pass(ctx: &Context) { 20 | let preference = ctx.options(|opt| opt.theme_preference); 21 | let has_changed = !ctx 22 | .data(|d| d.get_temp::(PLUGIN_ID)) 23 | .is_some_and(|old| old.0 == preference); 24 | if has_changed { 25 | ctx.send_viewport_cmd(ViewportCommand::SetTheme(to_system_theme(preference))); 26 | ctx.data_mut(|d| d.insert_temp(PLUGIN_ID, State(preference))); 27 | } 28 | } 29 | } 30 | 31 | fn to_system_theme(preference: ThemePreference) -> SystemTheme { 32 | match preference { 33 | ThemePreference::System => SystemTheme::SystemDefault, 34 | ThemePreference::Dark => SystemTheme::Dark, 35 | ThemePreference::Light => SystemTheme::Light, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/egui_ext.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::Context; 2 | use poll_promise::Promise; 3 | 4 | pub trait ContextExt { 5 | fn spawn_async( 6 | &self, 7 | future: impl std::future::Future + 'static + Send, 8 | ) -> Promise 9 | where 10 | T: Send + 'static; 11 | } 12 | 13 | impl ContextExt for Context { 14 | fn spawn_async( 15 | &self, 16 | future: impl std::future::Future + 'static + Send, 17 | ) -> Promise 18 | where 19 | T: Send + 'static, 20 | { 21 | let ctx = self.clone(); 22 | Promise::spawn_async(async move { 23 | let result = future.await; 24 | ctx.request_repaint(); 25 | result 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/font.rs: -------------------------------------------------------------------------------- 1 | use egui::{FontData, FontDefinitions, FontFamily, FontTweak}; 2 | use std::sync::Arc; 3 | 4 | pub const ICON_UPLOAD: char = '\u{f539}'; 5 | pub const ICON_DOWNLOAD: char = '\u{f27b}'; 6 | pub const ICON_CLIPBOARD_COPY: char = '\u{f211}'; 7 | pub const ICON_TICKET: char = '\u{f511}'; 8 | pub const ICON_X: char = '\u{f573}'; 9 | pub const ICON_ARROW_LEFT: char = '\u{f14c}'; 10 | pub const ICON_CHECK: char = '\u{f1e5}'; 11 | pub const ICON_LINK: char = '\u{f380}'; 12 | pub const ICON_REFRESH_CW: char = '\u{f464}'; 13 | pub const ICON_TAG: char = '\u{f4fe}'; 14 | 15 | const LUCIDE_FONT_NAME: &str = "lucide"; 16 | const INTER_MEDIUM: &str = "Inter Medium"; 17 | const INTER_BOLD: &str = "Inter Bold"; 18 | 19 | pub fn title_font_family() -> FontFamily { 20 | FontFamily::Name(Arc::from("Title")) 21 | } 22 | 23 | pub fn font_definitions() -> FontDefinitions { 24 | let mut fonts = FontDefinitions::default(); 25 | fonts.font_data.insert( 26 | INTER_MEDIUM.to_owned(), 27 | Arc::new(FontData::from_static(include_bytes!( 28 | "../assets/inter/Inter-Medium.otf" 29 | ))), 30 | ); 31 | fonts.font_data.insert( 32 | INTER_BOLD.to_owned(), 33 | Arc::new(FontData::from_static(include_bytes!( 34 | "../assets/inter/Inter-Bold.otf" 35 | ))), 36 | ); 37 | fonts.font_data.insert( 38 | LUCIDE_FONT_NAME.to_owned(), 39 | Arc::new( 40 | FontData::from_static(include_bytes!("../assets/lucide/lucide.ttf")).tweak(FontTweak { 41 | y_offset_factor: 0.07, 42 | ..Default::default() 43 | }), 44 | ), 45 | ); 46 | fonts.families.insert( 47 | FontFamily::Proportional, 48 | vec![INTER_MEDIUM.to_owned(), LUCIDE_FONT_NAME.to_owned()], 49 | ); 50 | fonts 51 | .families 52 | .insert(title_font_family(), vec![INTER_BOLD.to_owned()]); 53 | fonts 54 | } 55 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use egui::emath::Align; 2 | use egui::{self, Layout, Theme, Ui}; 3 | use egui_ext::ContextExt; 4 | use font::{font_definitions, ICON_X}; 5 | use main_view::{show_main_view, MainViewState}; 6 | use poll_promise::Promise; 7 | use std::error::Error; 8 | use version::{get_or_update_latest_app_version, AppVersion}; 9 | use visuals::Accent; 10 | use widgets::{app_menu, cancel_button, page, CancelLabel}; 11 | 12 | mod egui_ext; 13 | mod font; 14 | mod receive; 15 | pub(crate) use receive::*; 16 | mod send; 17 | pub(crate) use send::*; 18 | mod startup_action; 19 | pub use startup_action::*; 20 | mod auto_viewport_theme; 21 | mod main_view; 22 | mod transit_info; 23 | mod version; 24 | mod visuals; 25 | mod widgets; 26 | 27 | pub struct PortalApp { 28 | state: PortalAppState, 29 | version: Promise>, 30 | } 31 | 32 | enum PortalAppState { 33 | Main(MainViewState), 34 | UriError(Box), 35 | } 36 | 37 | impl Default for PortalAppState { 38 | fn default() -> Self { 39 | PortalAppState::Main(Default::default()) 40 | } 41 | } 42 | 43 | impl From for PortalAppState { 44 | fn from(value: StartupAction) -> Self { 45 | match value { 46 | StartupAction::ShowInvalidUriError(error) => PortalAppState::UriError(error), 47 | StartupAction::None => Default::default(), 48 | StartupAction::ReceiveFile(action) => PortalAppState::Main(MainViewState::from(action)), 49 | } 50 | } 51 | } 52 | 53 | impl PortalApp { 54 | pub fn new(cc: &eframe::CreationContext, action: StartupAction) -> Self { 55 | cc.egui_ctx.set_fonts(font_definitions()); 56 | auto_viewport_theme::register(&cc.egui_ctx); 57 | 58 | PortalApp { 59 | state: PortalAppState::from(action), 60 | version: cc 61 | .egui_ctx 62 | .spawn_async(get_or_update_latest_app_version(cc.egui_ctx.clone())), 63 | } 64 | } 65 | } 66 | 67 | impl eframe::App for PortalApp { 68 | fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { 69 | self.apply_accent(ctx); 70 | 71 | app_menu(ctx, self.version.ready().cloned().flatten()); 72 | 73 | egui::CentralPanel::default().show(ctx, |ui| { 74 | ui.with_layout(Layout::top_down(Align::Center), |ui| { 75 | match &mut self.state { 76 | PortalAppState::Main(main) => show_main_view(main, ui, frame), 77 | PortalAppState::UriError(error) => { 78 | if show_uri_error(ui, error.as_ref()) { 79 | update!( 80 | &mut self.state, 81 | PortalAppState::UriError(..) => PortalAppState::default()); 82 | } 83 | } 84 | } 85 | }); 86 | }); 87 | } 88 | } 89 | 90 | impl PortalApp { 91 | fn apply_accent(&self, ctx: &egui::Context) { 92 | let accent = self.accent(); 93 | ctx.style_mut_of(Theme::Dark, visuals::apply_accent(Theme::Dark, accent)); 94 | ctx.style_mut_of(Theme::Light, visuals::apply_accent(Theme::Light, accent)); 95 | } 96 | 97 | fn accent(&self) -> Accent { 98 | match &self.state { 99 | PortalAppState::Main(m) => m.accent(), 100 | PortalAppState::UriError(_) => Accent::Orange, 101 | } 102 | } 103 | } 104 | 105 | fn show_uri_error(ui: &mut Ui, error: &dyn Error) -> bool { 106 | let back_button_clicked = cancel_button(ui, CancelLabel::Back); 107 | page(ui, "Failed to open Link", error.to_string(), ICON_X); 108 | back_button_clicked 109 | } 110 | 111 | #[macro_export] 112 | macro_rules! update { 113 | ($target:expr, $pattern:pat => $match_arm:expr) => { 114 | ::replace_with::replace_with($target, Default::default, |target| match target { 115 | $pattern => $match_arm, 116 | _ => target, 117 | }); 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | use clap::Parser; 4 | use egui::{vec2, IconData, ViewportBuilder}; 5 | use portal::{PortalApp, StartupAction}; 6 | use std::error::Error; 7 | 8 | #[derive(Parser, Debug)] 9 | #[command(version)] 10 | struct Cli { 11 | #[arg(last = true)] 12 | uri: Option, 13 | } 14 | 15 | #[async_std::main] 16 | async fn main() -> Result<(), Box> { 17 | let args = Cli::parse(); 18 | 19 | // Log to stdout (if you run with `RUST_LOG=debug`). 20 | tracing_subscriber::fmt::init(); 21 | 22 | let mut viewport = ViewportBuilder::default().with_inner_size(vec2(320.0, 500.0)); 23 | if let Some(icon) = icon()? { 24 | viewport = viewport.with_icon(icon); 25 | } 26 | let options = eframe::NativeOptions { 27 | viewport, 28 | run_and_return: false, 29 | ..Default::default() 30 | }; 31 | let startup_action = StartupAction::from_uri(args.uri.as_deref()); 32 | eframe::run_native( 33 | "Portal", 34 | options, 35 | Box::new(move |cc| Ok(Box::new(PortalApp::new(cc, startup_action)))), 36 | )?; 37 | Ok(()) 38 | } 39 | 40 | #[cfg(not(any(windows, all(debug_assertions, target_os = "macos"))))] 41 | fn icon() -> Result, Box> { 42 | Ok(None) 43 | } 44 | 45 | #[cfg(all(debug_assertions, target_os = "macos"))] 46 | fn icon() -> Result, Box> { 47 | eframe::icon_data::from_png_bytes(include_bytes!( 48 | "../build/macos/AppIcon.iconset/icon_256x256@2x.png" 49 | )) 50 | .map(Some) 51 | .map_err(Into::into) 52 | } 53 | 54 | #[cfg(windows)] 55 | fn icon() -> Result, Box> { 56 | eframe::icon_data::from_png_bytes(include_bytes!("../build/windows/icon-256x256.png")) 57 | .map(Some) 58 | .map_err(Into::into) 59 | } 60 | -------------------------------------------------------------------------------- /src/main_view.rs: -------------------------------------------------------------------------------- 1 | use crate::font::{ICON_DOWNLOAD, ICON_UPLOAD}; 2 | use crate::visuals::Accent; 3 | use crate::widgets::toggle; 4 | use crate::{ReceiveFileAction, ReceiveView, SendView}; 5 | use egui::{hex_color, RichText, Ui}; 6 | 7 | #[derive(Default)] 8 | pub(crate) struct MainViewState { 9 | send_view: SendView, 10 | receive_view: ReceiveView, 11 | view_toggle: bool, 12 | } 13 | 14 | impl MainViewState { 15 | pub(crate) fn accent(&self) -> Accent { 16 | match View::from(self.view_toggle) { 17 | View::Send => Accent::Orange, 18 | View::Receive => Accent::Blue, 19 | } 20 | } 21 | } 22 | 23 | impl From for MainViewState { 24 | fn from(value: ReceiveFileAction) -> Self { 25 | MainViewState { 26 | receive_view: ReceiveView::new(value), 27 | view_toggle: true, 28 | ..Default::default() 29 | } 30 | } 31 | } 32 | 33 | pub(crate) fn show_main_view(state: &mut MainViewState, ui: &mut Ui, frame: &mut eframe::Frame) { 34 | let view = View::from(state.view_toggle); 35 | 36 | apply_style_overrides(view, ui.style_mut()); 37 | 38 | ui.add_enabled_ui(ui_enabled(state, view), |ui| { 39 | if show_switcher(state, view) { 40 | let font_size = 14.; 41 | ui.add_space(12.); 42 | ui.add(toggle( 43 | &mut state.view_toggle, 44 | RichText::new(format!("{ICON_UPLOAD} Send")).size(font_size), 45 | RichText::new(format!("{ICON_DOWNLOAD} Receive")).size(font_size), 46 | )); 47 | } 48 | 49 | state_ui(state, view, ui, frame); 50 | }); 51 | } 52 | 53 | fn apply_style_overrides(view: View, style: &mut egui::Style) { 54 | let (fill, stroke) = match view { 55 | View::Send if style.visuals.dark_mode => (hex_color!("#DB8400"), hex_color!("#38270E")), 56 | View::Send => (hex_color!("#FF9D0A"), hex_color!("#523A16")), 57 | View::Receive if style.visuals.dark_mode => (hex_color!("#27A7D8"), hex_color!("#183039")), 58 | View::Receive => (hex_color!("#73CDF0"), hex_color!("#183039")), 59 | }; 60 | style.visuals.selection.bg_fill = fill; 61 | style.visuals.selection.stroke.color = stroke; 62 | } 63 | 64 | fn show_switcher(state: &MainViewState, view: View) -> bool { 65 | match view { 66 | View::Send => matches!( 67 | state.send_view, 68 | SendView::Ready(..) | SendView::SelectingFile(..) 69 | ), 70 | View::Receive => state.receive_view.show_switcher(), 71 | } 72 | } 73 | 74 | fn ui_enabled(state: &MainViewState, view: View) -> bool { 75 | match view { 76 | View::Send => !matches!(state.send_view, SendView::SelectingFile(..)), 77 | View::Receive => true, 78 | } 79 | } 80 | 81 | fn state_ui(state: &mut MainViewState, view: View, ui: &mut egui::Ui, frame: &mut eframe::Frame) { 82 | match view { 83 | View::Send => state.send_view.ui(ui, frame), 84 | View::Receive => state.receive_view.ui(ui), 85 | } 86 | } 87 | 88 | #[derive(PartialEq, Copy, Clone)] 89 | enum View { 90 | Send, 91 | Receive, 92 | } 93 | 94 | impl From for View { 95 | fn from(value: bool) -> Self { 96 | if value { 97 | View::Receive 98 | } else { 99 | View::Send 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/receive.rs: -------------------------------------------------------------------------------- 1 | use crate::egui_ext::ContextExt; 2 | use crate::font::{ICON_CHECK, ICON_DOWNLOAD, ICON_X}; 3 | use crate::transit_info::TransitInfoDisplay; 4 | use crate::widgets::{ 5 | cancel_button, page, page_with_content, CancelLabel, PrimaryButton, MIN_BUTTON_SIZE, 6 | }; 7 | use crate::{update, ReceiveFileAction}; 8 | use eframe::egui::{Button, ProgressBar, TextEdit, Ui}; 9 | use egui::Key; 10 | use opener::{open, reveal}; 11 | use portal_proc_macro::states; 12 | use portal_wormhole::receive::{ 13 | connect, ConnectResult, ConnectingController, ReceiveRequestController, ReceiveResult, 14 | ReceivingController, 15 | }; 16 | use portal_wormhole::{Code, PortalError, Progress, TransitInfo}; 17 | use std::fmt; 18 | use std::path::{Path, PathBuf}; 19 | use ubyte::{ByteUnit, ToByteUnit}; 20 | 21 | #[derive(Default)] 22 | pub struct ReceiveView { 23 | state: ReceiveState, 24 | } 25 | 26 | impl ReceiveView { 27 | pub fn new(action: ReceiveFileAction) -> Self { 28 | Self { 29 | state: ReceiveState::Initial(action.code.to_string()), 30 | } 31 | } 32 | } 33 | 34 | impl Default for ReceiveState { 35 | fn default() -> Self { 36 | ReceiveState::Initial(String::default()) 37 | } 38 | } 39 | 40 | states! { 41 | enum ReceiveState; 42 | 43 | state Initial(code: String); 44 | 45 | async state Connecting(controller: ConnectingController, code: Code) -> ConnectResult { 46 | new(code: Code) { 47 | let (future, controller) = connect(code.clone()); 48 | (future, controller, code) 49 | } 50 | next { 51 | Ok(receive_request) => Connected(receive_request), 52 | Err(PortalError::Canceled) => Default::default(), 53 | Err(error) => Error(error), 54 | } 55 | } 56 | 57 | state Connected(controller: ReceiveRequestController); 58 | 59 | async state Rejecting() -> Result<(), PortalError> { 60 | new(request: ReceiveRequestController) { (request.reject(),) } 61 | next { 62 | Ok(()) => Default::default(), 63 | Err(error) => Error(error), 64 | } 65 | } 66 | 67 | async state Receiving(controller: ReceivingController, filename: String) -> ReceiveResult { 68 | new(receive_request: ReceiveRequestController) { 69 | let filename = receive_request.file_name(); 70 | let ctx = ui.ctx().clone(); 71 | let (future, controller) = receive_request.accept(move || ctx.request_repaint()); 72 | (Box::pin(future), controller, filename) 73 | } 74 | next { 75 | Ok(path) => Completed(path), 76 | Err(PortalError::Canceled) => Default::default(), 77 | Err(error) => Error(error), 78 | } 79 | } 80 | 81 | state Error(error: PortalError); 82 | 83 | state Completed(path: PathBuf); 84 | } 85 | 86 | impl ReceiveView { 87 | pub fn show_switcher(&self) -> bool { 88 | matches!(self.state, ReceiveState::Initial(_)) 89 | } 90 | 91 | pub fn ui(&mut self, ui: &mut Ui) { 92 | self.state.next(ui); 93 | 94 | match &mut self.state { 95 | ReceiveState::Initial(ref mut code) => { 96 | if let Some(ReceivePageResponse::Connect) = show_receive_file_page(ui, code) { 97 | update! { 98 | &mut self.state, 99 | ReceiveState::Initial(code) => ReceiveState::new_connecting(ui, Code(code)) 100 | } 101 | } 102 | } 103 | ReceiveState::Connecting(_, controller, code) => { 104 | show_connecting_page(ui, controller, code); 105 | } 106 | ReceiveState::Error(error) => { 107 | let error = error.to_string(); 108 | self.back_button(ui); 109 | page(ui, "File Transfer Failed", error, ICON_X); 110 | } 111 | ReceiveState::Connected(ref receive_request) => { 112 | if let Some(response) = show_connected_page(ui, receive_request) { 113 | update! { 114 | &mut self.state, 115 | ReceiveState::Connected(receive_request) => match response { 116 | ConnectedPageResponse::Accept => ReceiveState::new_receiving(ui, receive_request), 117 | ConnectedPageResponse::Reject => ReceiveState::new_rejecting(ui, receive_request), 118 | } 119 | } 120 | } 121 | } 122 | ReceiveState::Receiving(_, ref mut controller, ref filename) => { 123 | show_receiving_page(ui, controller, filename); 124 | } 125 | ReceiveState::Rejecting(_) => { 126 | page_with_content( 127 | ui, 128 | "Receive File", 129 | "Rejecting File Transfer", 130 | ICON_DOWNLOAD, 131 | |ui| { 132 | ui.spinner(); 133 | }, 134 | ); 135 | } 136 | ReceiveState::Completed(downloaded_path) => { 137 | if let Some(CompletedPageResponse::Back) = show_completed_page(ui, downloaded_path) 138 | { 139 | self.state = ReceiveState::default(); 140 | } 141 | } 142 | } 143 | } 144 | 145 | fn back_button(&mut self, ui: &mut Ui) { 146 | if cancel_button(ui, CancelLabel::Back) { 147 | self.state = ReceiveState::default(); 148 | } 149 | } 150 | } 151 | 152 | #[must_use] 153 | enum ReceivePageResponse { 154 | Connect, 155 | } 156 | 157 | fn show_receive_file_page(ui: &mut Ui, code: &mut String) -> Option { 158 | page_with_content( 159 | ui, 160 | "Receive File", 161 | "Enter the transmit code from the sender", 162 | ICON_DOWNLOAD, 163 | |ui| { 164 | if ui 165 | .add(TextEdit::singleline(code).hint_text("Code")) 166 | .lost_focus() 167 | && ui.input(|input| input.key_pressed(Key::Enter)) 168 | { 169 | return Some(ReceivePageResponse::Connect); 170 | } 171 | ui.add_space(5.0); 172 | 173 | let input_empty = code.is_empty() || code.chars().all(|c| c.is_whitespace()); 174 | 175 | ui.add_enabled_ui(!input_empty, |ui| { 176 | if ui 177 | .add(PrimaryButton::new("Receive File").min_size(MIN_BUTTON_SIZE)) 178 | .clicked() 179 | { 180 | Some(ReceivePageResponse::Connect) 181 | } else { 182 | None 183 | } 184 | }) 185 | .inner 186 | }, 187 | ) 188 | } 189 | 190 | #[must_use] 191 | enum ConnectedPageResponse { 192 | Accept, 193 | Reject, 194 | } 195 | 196 | fn show_connecting_page(ui: &mut Ui, controller: &mut ConnectingController, code: &Code) { 197 | if cancel_button(ui, CancelLabel::Cancel) { 198 | controller.cancel(); 199 | } 200 | 201 | page_with_content( 202 | ui, 203 | "Receive File", 204 | format!("Connecting with peer using transfer code \"{code}\""), 205 | ICON_DOWNLOAD, 206 | |ui| { 207 | ui.spinner(); 208 | }, 209 | ); 210 | } 211 | 212 | fn show_connected_page( 213 | ui: &mut Ui, 214 | receive_request: &ReceiveRequestController, 215 | ) -> Option { 216 | if cancel_button(ui, CancelLabel::Cancel) { 217 | return Some(ConnectedPageResponse::Reject); 218 | } 219 | 220 | let text = format!( 221 | "Your peer wants to send you \"{}\" (Size: {}).\nDo you want to download this file?", 222 | receive_request.file_name(), 223 | ByteDisplay(receive_request.filesize().bytes()) 224 | ); 225 | 226 | page_with_content(ui, "Receive File", text, ICON_DOWNLOAD, |ui| { 227 | if ui 228 | .add(PrimaryButton::new("Accept").min_size(MIN_BUTTON_SIZE)) 229 | .clicked() 230 | { 231 | return Some(ConnectedPageResponse::Accept); 232 | } 233 | 234 | None 235 | }) 236 | } 237 | 238 | fn show_receiving_page(ui: &mut Ui, controller: &mut ReceivingController, filename: &str) { 239 | let Progress { 240 | value: received, 241 | total, 242 | } = *controller.progress(); 243 | 244 | if cancel_button(ui, CancelLabel::Cancel) { 245 | controller.cancel(); 246 | } 247 | 248 | match controller.transit_info() { 249 | Some(transit_info) => page_with_content( 250 | ui, 251 | "Receiving File", 252 | transit_info_message(transit_info, filename), 253 | ICON_DOWNLOAD, 254 | |ui| { 255 | ui.add(ProgressBar::new((received as f64 / total as f64) as f32).animate(true)); 256 | }, 257 | ), 258 | None => page_with_content( 259 | ui, 260 | "Connected to Peer", 261 | format!("Preparing to receive file \"{filename}\""), 262 | ICON_DOWNLOAD, 263 | |ui| { 264 | ui.spinner(); 265 | }, 266 | ), 267 | } 268 | } 269 | 270 | fn transit_info_message(transit_info: &TransitInfo, filename: &str) -> String { 271 | format!("File \"{filename}\"{}", TransitInfoDisplay(transit_info)) 272 | } 273 | 274 | fn show_completed_page(ui: &mut Ui, downloaded_path: &Path) -> Option { 275 | if cancel_button(ui, CancelLabel::Back) { 276 | return Some(CompletedPageResponse::Back); 277 | } 278 | 279 | let filename = downloaded_path.file_name().expect("path with a file name"); 280 | 281 | page_with_content( 282 | ui, 283 | "File Transfer Successful", 284 | format!( 285 | "File \"{}\" has been saved to your Downloads folder", 286 | filename.to_string_lossy() 287 | ), 288 | ICON_CHECK, 289 | |ui| { 290 | if ui 291 | .add(PrimaryButton::new("Open File").min_size(MIN_BUTTON_SIZE)) 292 | .clicked() 293 | { 294 | _ = open(downloaded_path); 295 | } 296 | 297 | ui.add_space(5.0); 298 | 299 | if ui 300 | .add(Button::new("Show in Folder").min_size(MIN_BUTTON_SIZE)) 301 | .clicked() 302 | { 303 | _ = reveal(downloaded_path); 304 | } 305 | }, 306 | ); 307 | 308 | None 309 | } 310 | 311 | #[must_use] 312 | enum CompletedPageResponse { 313 | Back, 314 | } 315 | 316 | struct ByteDisplay(ByteUnit); 317 | 318 | // Same as https://github.com/SergioBenitez/ubyte/blob/master/src/byte_unit.rs#L442 319 | // except with a space between value and suffix. 320 | impl fmt::Display for ByteDisplay { 321 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 322 | const NO_BREAK_SPACE: &str = "\u{00A0}"; 323 | let (whole, rem, suffix, unit) = self.0.repr(); 324 | let width = f.width().unwrap_or(0); 325 | if rem != 0f64 && f.precision().map(|p| p > 0).unwrap_or(true) { 326 | let p = f.precision().unwrap_or(2); 327 | let k = 10u64.saturating_pow(p as u32) as f64; 328 | write!( 329 | f, 330 | "{:0width$}.{:0p$.0}{NO_BREAK_SPACE}{}", 331 | whole, 332 | rem * k, 333 | suffix, 334 | ) 335 | } else if rem > 0.5f64 { 336 | ((whole.bytes() + 1) * unit).fmt(f) 337 | } else { 338 | write!(f, "{whole:0width$}{NO_BREAK_SPACE}{suffix}") 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/send.rs: -------------------------------------------------------------------------------- 1 | use crate::egui_ext::ContextExt; 2 | use crate::font::{ICON_CHECK, ICON_CLIPBOARD_COPY, ICON_LINK, ICON_TICKET, ICON_UPLOAD, ICON_X}; 3 | use crate::transit_info::TransitInfoDisplay; 4 | use crate::update; 5 | use crate::widgets::{ 6 | cancel_button, page, page_with_content, CancelLabel, PrimaryButton, MIN_BUTTON_SIZE, 7 | }; 8 | use eframe::egui::{Button, Key, Modifiers, ProgressBar, Ui}; 9 | use egui::{InputState, RichText}; 10 | use portal_proc_macro::states; 11 | use portal_wormhole::send::{send, SendRequest, SendingController, SendingProgress}; 12 | use portal_wormhole::{Code, PortalError, Progress, SharableWormholeTransferUri}; 13 | use rfd::{AsyncFileDialog, FileHandle}; 14 | use std::fmt; 15 | use std::future::Future; 16 | use std::path::{Path, PathBuf}; 17 | 18 | states! { 19 | pub enum SendView; 20 | 21 | state Ready(); 22 | 23 | async state SelectingFile() -> Option> { 24 | new(pick_future: impl Future>> + Send + 'static) { 25 | (Box::pin(pick_future),) 26 | } 27 | next { 28 | None => Ready(), 29 | Some(paths) => { 30 | if let Some(request) = SendRequest::from_paths(paths.into_iter().map(|p| p.path().to_owned()).collect()) { 31 | SendView::new_sending(ui, request) 32 | } else { 33 | Ready() 34 | } 35 | } 36 | } 37 | } 38 | 39 | async state Sending(controller: SendingController, request: SendRequest) -> Result<(), (PortalError, SendRequest)> { 40 | new(request: SendRequest) { 41 | let ctx = ui.ctx().clone(); 42 | let (future, controller) = send(request.clone(), move || ctx.request_repaint()); 43 | (Box::pin(future), controller, request) 44 | } 45 | next { 46 | Ok(_) => Complete(request), 47 | Err((PortalError::Canceled, _)) => SendView::default(), 48 | Err((error, send_request)) => Error(error, send_request), 49 | } 50 | } 51 | 52 | state Error(error: PortalError, send_request: SendRequest); 53 | 54 | state Complete(request: SendRequest); 55 | } 56 | 57 | impl Default for SendView { 58 | fn default() -> Self { 59 | SendView::Ready() 60 | } 61 | } 62 | 63 | impl SendView { 64 | pub fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { 65 | self.next(ui); 66 | 67 | if let SendView::Ready() | SendView::Complete(..) = self { 68 | self.accept_dropped_file(ui); 69 | } 70 | 71 | match self { 72 | SendView::Ready() | SendView::SelectingFile(..) => { 73 | self.show_file_selection_page(ui, frame) 74 | } 75 | SendView::Sending(_, ref mut controller, ref send_request) => { 76 | show_transfer_progress(ui, controller, send_request) 77 | } 78 | SendView::Error(ref error, _) => self.show_error_page(ui, error.to_string()), 79 | SendView::Complete(ref send_request) => { 80 | self.show_transfer_completed_page(ui, send_request.clone()) 81 | } 82 | } 83 | } 84 | 85 | fn show_file_selection_page(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { 86 | page_with_content( 87 | ui, 88 | "Send File", 89 | "Select or drop the file or directory to send.", 90 | ICON_UPLOAD, 91 | |ui| self.show_file_selection(ui, frame), 92 | ); 93 | } 94 | 95 | fn show_file_selection(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { 96 | let select_file_button = PrimaryButton::new("Select File").min_size(MIN_BUTTON_SIZE); 97 | if ui.add(select_file_button).clicked() 98 | || ui.input_mut(|input| input.consume_key(Modifiers::COMMAND, Key::O)) 99 | { 100 | *self = SendView::new_selecting_file( 101 | ui, 102 | AsyncFileDialog::new().set_parent(frame).pick_files(), 103 | ); 104 | } 105 | 106 | ui.add_space(5.0); 107 | 108 | let select_folder_button = Button::new("Select Folder").min_size(MIN_BUTTON_SIZE); 109 | if ui.add(select_folder_button).clicked() { 110 | *self = SendView::new_selecting_file( 111 | ui, 112 | AsyncFileDialog::new().set_parent(frame).pick_folders(), 113 | ); 114 | } 115 | } 116 | 117 | fn show_error_page(&mut self, ui: &mut Ui, error: String) { 118 | self.back_button(ui); 119 | 120 | page_with_content(ui, "File Transfer Failed", error, ICON_X, |ui| { 121 | if ui.button("Retry").clicked() { 122 | update!( 123 | self, 124 | SendView::Error(_, send_request) => SendView::new_sending(ui, send_request) 125 | ); 126 | } 127 | }); 128 | } 129 | 130 | fn show_transfer_completed_page(&mut self, ui: &mut Ui, send_request: SendRequest) { 131 | let request_title = SendRequestDisplay(&send_request).to_string(); 132 | self.back_button(ui); 133 | page( 134 | ui, 135 | "File Transfer Successful", 136 | format!("Successfully sent {request_title}"), 137 | ICON_CHECK, 138 | ); 139 | } 140 | 141 | fn accept_dropped_file(&mut self, ui: &mut Ui) { 142 | if ui.is_enabled() { 143 | let dropped_file_paths: Vec<_> = ui.ctx().input(dropped_file_paths); 144 | 145 | if let Some(send_request) = SendRequest::from_paths(dropped_file_paths) { 146 | *self = SendView::new_sending(ui, send_request) 147 | } 148 | } 149 | } 150 | 151 | fn back_button(&mut self, ui: &mut Ui) { 152 | if cancel_button(ui, CancelLabel::Back) { 153 | *self = SendView::default(); 154 | } 155 | } 156 | } 157 | 158 | fn dropped_file_paths(input: &InputState) -> Vec { 159 | input 160 | .raw 161 | .dropped_files 162 | .iter() 163 | .filter_map(|f| f.path.clone()) 164 | .collect() 165 | } 166 | 167 | fn show_transfer_progress( 168 | ui: &mut Ui, 169 | controller: &mut SendingController, 170 | send_request: &SendRequest, 171 | ) { 172 | if cancel_button(ui, CancelLabel::Cancel) { 173 | controller.cancel(); 174 | } 175 | 176 | match controller.progress() { 177 | SendingProgress::Packing => show_packing_progress(ui, send_request), 178 | SendingProgress::Connecting => show_transmit_code_progress(ui), 179 | SendingProgress::Connected(code) => show_transmit_code(ui, code, send_request), 180 | SendingProgress::PreparingToSend => page_with_content( 181 | ui, 182 | "Connected to Peer", 183 | format!("Preparing to send {}", SendRequestDisplay(send_request)), 184 | ICON_UPLOAD, 185 | |ui| { 186 | ui.spinner(); 187 | }, 188 | ), 189 | SendingProgress::Sending(transit_info, Progress { value: sent, total }) => { 190 | page_with_content( 191 | ui, 192 | "Sending File", 193 | format!( 194 | "{}{}", 195 | SendRequestDisplay(send_request), 196 | TransitInfoDisplay(transit_info) 197 | ), 198 | ICON_UPLOAD, 199 | |ui| { 200 | ui.add(ProgressBar::new((*sent as f64 / *total as f64) as f32).animate(true)); 201 | }, 202 | ) 203 | } 204 | } 205 | } 206 | 207 | fn show_packing_progress(ui: &mut Ui, send_request: &SendRequest) { 208 | page_with_content( 209 | ui, 210 | "Send File", 211 | format!( 212 | "Packing {} to a Zip file...", 213 | SendRequestDisplay(send_request) 214 | ), 215 | ICON_UPLOAD, 216 | |ui| { 217 | ui.spinner(); 218 | }, 219 | ) 220 | } 221 | 222 | fn show_transmit_code_progress(ui: &mut Ui) { 223 | page_with_content( 224 | ui, 225 | "Send File", 226 | "Generating transmit code...", 227 | ICON_UPLOAD, 228 | |ui| { 229 | ui.spinner(); 230 | }, 231 | ) 232 | } 233 | 234 | fn show_transmit_code(ui: &mut Ui, code: &Code, send_request: &SendRequest) { 235 | page_with_content( 236 | ui, 237 | "Your Transmit Code", 238 | format!( 239 | "Ready to send {}.\nThe receiver needs to enter this code to begin the file transfer.", 240 | SendRequestDisplay(send_request) 241 | ), 242 | ICON_TICKET, 243 | |ui| { 244 | ui.label(RichText::new(code.to_string()).size(15.).strong()); 245 | ui.add_space(5.); 246 | if ui 247 | .button(format!("{ICON_CLIPBOARD_COPY} Copy Code")) 248 | .on_hover_text("Click to copy") 249 | .clicked() 250 | || ui.input_mut(|input| input.consume_key(Modifiers::COMMAND, Key::C)) 251 | { 252 | ui.output_mut(|output| output.copied_text = code.to_string()); 253 | } 254 | 255 | if ui 256 | .button(format!("{ICON_LINK} Copy Link")) 257 | .on_hover_text("Click to copy") 258 | .clicked() 259 | { 260 | ui.output_mut(|output| { 261 | output.copied_text = SharableWormholeTransferUri::new(code.clone()).to_string(); 262 | }); 263 | } 264 | }, 265 | ); 266 | } 267 | 268 | struct SendRequestDisplay<'a>(&'a SendRequest); 269 | 270 | impl fmt::Display for SendRequestDisplay<'_> { 271 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 272 | match self.0 { 273 | SendRequest::File(path) => write!(f, "file \"{}\"", filename_or_self(path).display()), 274 | SendRequest::Folder(path) => { 275 | write!(f, "folder \"{}\"", filename_or_self(path).display()) 276 | } 277 | SendRequest::Selection(_) => write!(f, "selection"), 278 | SendRequest::Cached(original_request, _) => { 279 | write!(f, "{}", SendRequestDisplay(original_request)) 280 | } 281 | } 282 | } 283 | } 284 | 285 | fn filename_or_self(path: &Path) -> &Path { 286 | path.file_name().map(Path::new).unwrap_or(path) 287 | } 288 | -------------------------------------------------------------------------------- /src/startup_action.rs: -------------------------------------------------------------------------------- 1 | use portal_wormhole::{Code, WormholeTransferUri}; 2 | use std::error::Error; 3 | use std::str::FromStr; 4 | use thiserror::Error; 5 | 6 | #[derive(Default, Debug)] 7 | pub enum StartupAction { 8 | #[default] 9 | None, 10 | ReceiveFile(ReceiveFileAction), 11 | ShowInvalidUriError(Box), 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct ReceiveFileAction { 16 | pub code: Code, 17 | } 18 | 19 | impl StartupAction { 20 | pub fn from_uri(uri: Option<&str>) -> Self { 21 | uri.map(Self::from_uri_str).unwrap_or_default() 22 | } 23 | 24 | fn from_uri_str(uri: &str) -> Self { 25 | WormholeTransferUri::from_str(uri) 26 | .map(Self::from_wormhole_transfer_uri) 27 | .unwrap_or_else(|error| StartupAction::ShowInvalidUriError(error.into())) 28 | } 29 | 30 | fn from_wormhole_transfer_uri(uri: WormholeTransferUri) -> Self { 31 | if uri.is_leader || uri.rendezvous_server.is_some() { 32 | StartupAction::ShowInvalidUriError(UnsupportedWormholeUriError(uri).into()) 33 | } else { 34 | StartupAction::ReceiveFile(ReceiveFileAction { code: uri.code }) 35 | } 36 | } 37 | } 38 | 39 | #[derive(Error, Debug)] 40 | #[error("Unsupported wormhole-transfer URI: {}", ToString::to_string(.0))] 41 | struct UnsupportedWormholeUriError(WormholeTransferUri); 42 | -------------------------------------------------------------------------------- /src/transit_info.rs: -------------------------------------------------------------------------------- 1 | use portal_wormhole::{ConnectionType, TransitInfo}; 2 | 3 | use std::fmt; 4 | 5 | pub struct TransitInfoDisplay<'a>(pub &'a TransitInfo); 6 | 7 | impl fmt::Display for TransitInfoDisplay<'_> { 8 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 9 | use ConnectionType::*; 10 | match &self.0.conn_type { 11 | Direct => write!(f, " via direct transfer"), 12 | Relay { name: None } => write!(f, " via relay"), 13 | Relay { name: Some(relay) } => write!(f, " via relay \"{relay}\""), 14 | _ => Ok(()), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use egui::{Context, Id}; 2 | use log::warn; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::time::{Duration, SystemTime}; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | pub(crate) struct AppVersion { 9 | pub(crate) label: String, 10 | pub(crate) tag_name: String, 11 | pub(crate) release_notes_url: String, 12 | pub(crate) source_code_url: String, 13 | pub(crate) report_issue_url: String, 14 | } 15 | 16 | impl AppVersion { 17 | #[cfg(debug_assertions)] 18 | pub(crate) fn current() -> AppVersion { 19 | AppVersion { 20 | label: "dev".to_owned(), 21 | tag_name: TagName(env!("CARGO_PKG_VERSION")).to_string(), 22 | release_notes_url: env!("CARGO_PKG_REPOSITORY").to_owned(), 23 | source_code_url: env!("CARGO_PKG_REPOSITORY").to_owned(), 24 | report_issue_url: report_issues_url(), 25 | } 26 | } 27 | 28 | #[cfg(not(debug_assertions))] 29 | pub(crate) fn current() -> AppVersion { 30 | AppVersion { 31 | label: env!("CARGO_PKG_VERSION").to_owned(), 32 | tag_name: TagName(env!("CARGO_PKG_VERSION")).to_string(), 33 | release_notes_url: format!( 34 | "{repo}/releases/tag/{tag}", 35 | repo = env!("CARGO_PKG_REPOSITORY"), 36 | tag = TagName(env!("CARGO_PKG_VERSION")) 37 | ), 38 | source_code_url: format!( 39 | "{repo}/tree/{tag}", 40 | repo = env!("CARGO_PKG_REPOSITORY"), 41 | tag = TagName(env!("CARGO_PKG_VERSION")) 42 | ), 43 | report_issue_url: report_issues_url(), 44 | } 45 | } 46 | } 47 | 48 | pub(crate) async fn get_or_update_latest_app_version(ctx: Context) -> Option { 49 | match ctx 50 | .memory_mut(|m| m.data.get_persisted::(Id::NULL)) 51 | .filter(is_recent) 52 | { 53 | Some(saved) => saved.version, 54 | None => { 55 | let version = fetch_latest_version().await; 56 | store_app_version(ctx, version.clone()); 57 | version 58 | } 59 | } 60 | } 61 | 62 | fn is_recent(saved: &SavedAppVersion) -> bool { 63 | let day = Duration::from_secs(60 * 60 * 24); 64 | saved 65 | .last_checked 66 | .elapsed() 67 | .map(|d| d < day) 68 | .unwrap_or(false) 69 | } 70 | 71 | fn store_app_version(ctx: Context, version: Option) { 72 | let last_checked = SystemTime::now(); 73 | let saved_version = SavedAppVersion { 74 | version, 75 | last_checked, 76 | }; 77 | ctx.memory_mut(|m| m.data.insert_persisted(Id::NULL, saved_version)); 78 | } 79 | 80 | #[derive(Debug, Clone, Serialize, Deserialize)] 81 | struct SavedAppVersion { 82 | version: Option, 83 | last_checked: SystemTime, 84 | } 85 | 86 | async fn fetch_latest_version() -> Option { 87 | let result = surf::get(latest_release_api_url()) 88 | .recv_json::() 89 | .await; 90 | match result { 91 | Ok(release) => Some(AppVersion { 92 | label: release.name, 93 | tag_name: release.tag_name.clone(), 94 | release_notes_url: format!( 95 | "{repo}/releases/latest", 96 | repo = env!("CARGO_PKG_REPOSITORY") 97 | ), 98 | source_code_url: format!( 99 | "{repo}/tree/{tag}", 100 | repo = env!("CARGO_PKG_REPOSITORY"), 101 | tag = release.tag_name 102 | ), 103 | report_issue_url: report_issues_url(), 104 | }), 105 | Err(err) => { 106 | warn!( 107 | err:? = err; 108 | "Failed to fetch latest version from GitHub" 109 | ); 110 | None 111 | } 112 | } 113 | } 114 | 115 | #[derive(Debug, Serialize, Deserialize)] 116 | struct GitHubRelease { 117 | html_url: String, 118 | tag_name: String, 119 | name: String, 120 | } 121 | 122 | struct TagName<'a>(&'a str); 123 | 124 | impl fmt::Display for TagName<'_> { 125 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 126 | write!(f, "v")?; 127 | self.0.fmt(f) 128 | } 129 | } 130 | 131 | fn report_issues_url() -> String { 132 | format!("{repo}/issues/new", repo = env!("CARGO_PKG_REPOSITORY")) 133 | } 134 | 135 | fn latest_release_api_url() -> String { 136 | format!("{}/releases/latest", env!("CARGO_PKG_REPOSITORY")) 137 | .replace("https://github.com/", "https://api.github.com/repos/") 138 | } 139 | -------------------------------------------------------------------------------- /src/visuals.rs: -------------------------------------------------------------------------------- 1 | use egui::epaint::hex_color; 2 | use egui::{Style, Theme}; 3 | 4 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 5 | pub(crate) enum Accent { 6 | Orange, 7 | Blue, 8 | } 9 | 10 | pub(crate) fn apply_accent(theme: Theme, accent: Accent) -> impl FnOnce(&mut Style) { 11 | move |style| { 12 | let (fill, stroke) = match (accent, theme) { 13 | (Accent::Orange, Theme::Dark) => (hex_color!("#DB8400"), hex_color!("#38270E")), 14 | (Accent::Orange, Theme::Light) => (hex_color!("#FF9D0A"), hex_color!("#523A16")), 15 | (Accent::Blue, Theme::Dark) => (hex_color!("#27A7D8"), hex_color!("#183039")), 16 | (Accent::Blue, Theme::Light) => (hex_color!("#73CDF0"), hex_color!("#183039")), 17 | }; 18 | style.visuals.selection.bg_fill = fill; 19 | style.visuals.selection.stroke.color = stroke; 20 | style.visuals.hyperlink_color = fill; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/widgets.rs: -------------------------------------------------------------------------------- 1 | mod cancel; 2 | pub use self::cancel::*; 3 | mod page; 4 | pub use self::page::*; 5 | mod primary_button; 6 | pub use self::primary_button::*; 7 | use egui::Vec2; 8 | mod toggle; 9 | pub use toggle::*; 10 | mod menu; 11 | pub(crate) use menu::*; 12 | 13 | pub const MIN_BUTTON_SIZE: Vec2 = Vec2::new(100.0, 0.0); 14 | -------------------------------------------------------------------------------- /src/widgets/cancel.rs: -------------------------------------------------------------------------------- 1 | use crate::font::{ICON_ARROW_LEFT, ICON_X}; 2 | use egui::{Key, Modifiers, Ui}; 3 | use std::fmt; 4 | 5 | pub fn cancel_button(ui: &mut Ui, label: CancelLabel) -> bool { 6 | ui.horizontal(|ui| ui.button(format!("{label}")).clicked()) 7 | .inner 8 | || ui.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Escape)) 9 | } 10 | 11 | pub enum CancelLabel { 12 | Cancel, 13 | Back, 14 | } 15 | 16 | impl fmt::Display for CancelLabel { 17 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 | match self { 19 | CancelLabel::Cancel => write!(f, "{ICON_X} Cancel"), 20 | CancelLabel::Back => write!(f, "{ICON_ARROW_LEFT} Back"), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/widgets/menu.rs: -------------------------------------------------------------------------------- 1 | use crate::font::{ICON_REFRESH_CW, ICON_TAG}; 2 | use crate::version::AppVersion; 3 | use egui::{menu, OpenUrl}; 4 | use egui_theme_switch::global_theme_switch; 5 | 6 | pub(crate) fn app_menu(ctx: &egui::Context, latest_version: Option) { 7 | egui::TopBottomPanel::top("top panel").show(ctx, |ui| { 8 | menu::bar(ui, |ui| { 9 | let version = AppVersion::current(); 10 | 11 | ui.menu_button("View", |ui| { 12 | global_theme_switch(ui); 13 | }); 14 | 15 | ui.menu_button("Help", |ui| { 16 | if ui.button("Source code").clicked() { 17 | ctx.open_url(OpenUrl::new_tab(version.source_code_url)) 18 | } 19 | 20 | if ui.button("Report a bug").clicked() { 21 | ctx.open_url(OpenUrl::new_tab(version.report_issue_url)); 22 | } 23 | 24 | ui.separator(); 25 | ui.hyperlink_to( 26 | format!("{ICON_TAG} {}", version.label), 27 | version.release_notes_url, 28 | ); 29 | }); 30 | 31 | if let Some(latest_version) = latest_version.filter(|v| v.tag_name != version.tag_name) 32 | { 33 | ui.add_space(2.); 34 | ui.hyperlink_to( 35 | format!( 36 | "{ICON_REFRESH_CW} Update to {version}", 37 | version = latest_version.label 38 | ), 39 | latest_version.release_notes_url, 40 | ); 41 | } 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/widgets/page.rs: -------------------------------------------------------------------------------- 1 | use crate::font::title_font_family; 2 | use egui::{RichText, Ui, WidgetText}; 3 | 4 | pub fn page( 5 | ui: &mut Ui, 6 | title: impl Into, 7 | text: impl Into, 8 | icon: impl Into>, 9 | ) where 10 | I: Into, 11 | { 12 | if let Some(icon) = icon.into() { 13 | ui.add_space(15.); 14 | ui.label(RichText::new(icon).size(120.0)); 15 | ui.add_space(5.); 16 | } 17 | ui.add_space(10.); 18 | ui.label(title.into().size(30.0).family(title_font_family()).strong()); 19 | ui.add_space(10.); 20 | ui.label(text); 21 | } 22 | 23 | pub fn page_with_content( 24 | ui: &mut Ui, 25 | title: impl Into, 26 | text: impl Into, 27 | icon: impl Into>, 28 | add_contents: impl FnOnce(&mut Ui) -> T, 29 | ) -> T 30 | where 31 | I: Into, 32 | { 33 | page(ui, title, text, icon); 34 | ui.add_space(20.0); 35 | add_contents(ui) 36 | } 37 | -------------------------------------------------------------------------------- /src/widgets/primary_button.rs: -------------------------------------------------------------------------------- 1 | use egui::{Button, Response, Ui, Vec2, Widget, WidgetText}; 2 | 3 | pub struct PrimaryButton { 4 | text: WidgetText, 5 | min_size: Vec2, 6 | } 7 | 8 | impl PrimaryButton { 9 | pub fn new(text: impl Into) -> Self { 10 | Self { 11 | text: text.into(), 12 | min_size: Vec2::ZERO, 13 | } 14 | } 15 | 16 | pub fn min_size(mut self, min_size: Vec2) -> Self { 17 | self.min_size = min_size; 18 | self 19 | } 20 | } 21 | 22 | impl Widget for PrimaryButton { 23 | fn ui(self, ui: &mut Ui) -> Response { 24 | let fill_color = ui.style().visuals.selection.bg_fill; 25 | let text_color = ui.style().visuals.selection.stroke.color; 26 | Button::new(self.text.color(text_color)) 27 | .fill(fill_color) 28 | .min_size(self.min_size) 29 | .ui(ui) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/widgets/toggle.rs: -------------------------------------------------------------------------------- 1 | use egui::{vec2, Galley, Pos2, Rect, Response, TextStyle, Ui, Vec2, WidgetText}; 2 | use std::sync::Arc; 3 | 4 | /// Two-state toggle switch with labels on both sides. 5 | /// ``` text 6 | /// _____________ 7 | /// / /.....\ 8 | /// Off | |.......| On 9 | /// \_______\_____/ 10 | /// ``` 11 | /// 12 | /// ## Example: 13 | /// ```ignore 14 | /// ui.add(toggle(&mut my_bool, "Off", "On")); 15 | /// ``` 16 | pub fn toggle<'a>( 17 | on: &'a mut bool, 18 | text_left: impl Into + 'a, 19 | text_right: impl Into + 'a, 20 | ) -> impl egui::Widget + 'a { 21 | move |ui: &mut egui::Ui| toggle_ui(ui, on, text_left, text_right) 22 | } 23 | 24 | fn toggle_ui( 25 | ui: &mut egui::Ui, 26 | on: &mut bool, 27 | text_left: impl Into, 28 | text_right: impl Into, 29 | ) -> egui::Response { 30 | let (space, mut response) = allocate_space(ui, text_left.into(), text_right.into()); 31 | 32 | if response.clicked() { 33 | *on = !*on; 34 | response.mark_changed(); 35 | } 36 | 37 | response.widget_info(|| { 38 | egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, "") 39 | }); 40 | 41 | if ui.is_rect_visible(space.rect) { 42 | paint(ui, *on, &response, space); 43 | } 44 | 45 | response 46 | } 47 | 48 | fn allocate_space( 49 | ui: &mut egui::Ui, 50 | text_left: WidgetText, 51 | text_right: WidgetText, 52 | ) -> (AllocatedSpace, Response) { 53 | let toggle_size = ui.spacing().interact_size.y * egui::vec2(2.5, 1.25); 54 | let toggle_spacing = ui.spacing().item_spacing.y * 3.5; 55 | 56 | let available_width = ui.available_width() - toggle_size.x - toggle_spacing * 2.; 57 | let text_left = text_left.into_galley(ui, None, available_width / 2.0, TextStyle::Button); 58 | let text_right = text_right.into_galley(ui, None, available_width / 2.0, TextStyle::Button); 59 | 60 | // We want the toggle button to be centered, even if the 61 | // two texts have different sizes, so we allocate twice the max size. 62 | let max_text_size = max_size(text_left.size(), text_right.size()); 63 | 64 | let (rect, response) = ui.allocate_exact_size( 65 | toggle_size + max_text_size * vec2(2., 0.) + vec2(toggle_spacing * 2., 0.), 66 | egui::Sense::click(), 67 | ); 68 | 69 | let space = AllocatedSpace { 70 | rect, 71 | text_left, 72 | text_right, 73 | max_text_size, 74 | toggle_size, 75 | }; 76 | 77 | (space, response) 78 | } 79 | 80 | fn partition_space( 81 | AllocatedSpace { 82 | rect, 83 | toggle_size, 84 | text_left, 85 | text_right, 86 | max_text_size, 87 | }: &AllocatedSpace, 88 | ) -> (Rect, Rect, Rect) { 89 | let toggle_rect = Rect::from_center_size(rect.center(), *toggle_size); 90 | 91 | let text_left_rect = Rect::from_center_size( 92 | rect.left_center() + text_offset(text_left.size(), *max_text_size), 93 | text_left.size(), 94 | ); 95 | 96 | let text_right_rect = Rect::from_center_size( 97 | rect.right_center() - text_offset(text_right.size(), *max_text_size), 98 | text_right.size(), 99 | ); 100 | 101 | (text_left_rect, toggle_rect, text_right_rect) 102 | } 103 | 104 | fn paint(ui: &mut egui::Ui, on: bool, response: &Response, space: AllocatedSpace) { 105 | let (text_left_rect, toggle_rect, text_right_rect) = partition_space(&space); 106 | paint_text(ui, response, space.text_left, text_left_rect.min, !on); 107 | paint_toggle(ui, response, toggle_rect, on); 108 | paint_text(ui, response, space.text_right, text_right_rect.min, on); 109 | } 110 | 111 | fn paint_text(ui: &mut Ui, response: &Response, text: Arc, pos: Pos2, selected: bool) { 112 | let visuals = ui.style().interact_selectable(response, selected); 113 | 114 | let color = if selected { 115 | visuals.bg_fill 116 | } else { 117 | ui.style().visuals.strong_text_color() 118 | }; 119 | 120 | ui.painter().galley(pos, text, color); 121 | } 122 | 123 | // Adopted from https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/demo/toggle_switch.rs 124 | fn paint_toggle(ui: &mut Ui, response: &Response, rect: Rect, on: bool) { 125 | let visuals = ui.style().interact_selectable(response, true); 126 | 127 | let radius = 0.5 * rect.height(); 128 | ui.painter() 129 | .rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); 130 | 131 | let how_on = ui.ctx().animate_bool(response.id, on); 132 | let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); 133 | let center = egui::pos2(circle_x, rect.center().y); 134 | ui.painter() 135 | .circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); 136 | } 137 | 138 | fn text_offset(text_size: Vec2, max_text_size: Vec2) -> Vec2 { 139 | vec2(text_size.x / 2. + (max_text_size.x - text_size.x), 0.) 140 | } 141 | 142 | struct AllocatedSpace { 143 | rect: Rect, 144 | text_left: Arc, 145 | text_right: Arc, 146 | max_text_size: Vec2, 147 | toggle_size: Vec2, 148 | } 149 | 150 | fn max_size(v1: Vec2, v2: Vec2) -> Vec2 { 151 | vec2(partial_max(v1.x, v2.x), partial_max(v1.y, v2.y)) 152 | } 153 | 154 | fn partial_max(v1: T, v2: T) -> T 155 | where 156 | T: PartialOrd, 157 | { 158 | if v2 > v1 { 159 | v2 160 | } else { 161 | v1 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | ico-builder = "0.1.0" 8 | 9 | [lints] 10 | workspace = true 11 | -------------------------------------------------------------------------------- /xtask/src/main.rs: -------------------------------------------------------------------------------- 1 | use ico_builder::IcoBuilder; 2 | use std::error::Error; 3 | 4 | fn main() -> Result<(), Box> { 5 | match std::env::args().nth(1).as_deref() { 6 | Some("update-ico") => update_ico(), 7 | Some(subcommand) => Err(format!("Unknown subcommand '{subcommand}'").into()), 8 | None => Err("Missing subcommand".into()), 9 | } 10 | } 11 | 12 | fn update_ico() -> Result<(), Box> { 13 | IcoBuilder::default() 14 | .add_source_file("build/windows/icon-32x32.png") 15 | .add_source_file("build/windows/icon-256x256.png") 16 | .build_file("build/windows/portal.ico") 17 | .map_err(Into::into) 18 | } 19 | --------------------------------------------------------------------------------