diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..b99dd9d78 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(cd:*)", "WebFetch(domain:v2.tauri.app)"] + } +} diff --git a/Cargo.lock b/Cargo.lock index d6577b0fe..4b52f1a15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1743,6 +1743,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -2258,6 +2278,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "do-notation" version = "0.1.3" @@ -4788,6 +4817,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -6090,6 +6129,16 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6408,6 +6457,7 @@ dependencies = [ "tauri-plugin-barcode-scanner", "tauri-plugin-biometric", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", @@ -6415,6 +6465,7 @@ dependencies = [ "tauri-plugin-safe-area-insets", "tauri-plugin-sage", "tauri-plugin-sharesheet", + "tauri-plugin-single-instance", "tauri-plugin-window-state", "tauri-specta", "tokio", @@ -7551,6 +7602,27 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.2" @@ -7668,6 +7740,22 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -7978,6 +8066,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -9137,6 +9234,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index af545fa20..f9236e959 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,8 +76,10 @@ tauri-plugin-barcode-scanner = "2.4.4" tauri-plugin-biometric = "2.3.2" tauri-plugin-safe-area-insets = "0.1.0" tauri-plugin-sage = { path = "./tauri-plugin-sage" } +tauri-plugin-deep-link = "2.4.7" tauri-build = "2.5.5" tauri-plugin = "2.5.3" +tauri-plugin-single-instance = "2.4.0" # Specta specta = "2.0.0-rc.22" diff --git a/docs/DEEP_LINKING.md b/docs/DEEP_LINKING.md new file mode 100644 index 000000000..ec17a9a73 --- /dev/null +++ b/docs/DEEP_LINKING.md @@ -0,0 +1,384 @@ +# Deep Linking in Sage + +Sage supports custom URL scheme deep linking via the `sage:` protocol. When a `sage:` URL is opened, the app will launch (or come to focus if already running) and navigate to the appropriate screen. + +## URL Formats + +### Offer Links + +``` +sage:[?fee=] +``` + +| Parameter | Required | Description | +| --------- | -------- | ------------------------------------------------- | +| `offer_string` | Yes | A valid Chia offer string starting with `offer1`| +| `fee` | No | Network fee in mojos to prepopulate when taking the offer | + +**Examples:** + +```bash +# Basic offer link +sage:offer1qqr83wcuu2rykcmqvpsxvgqq... + +# Offer link with pre-filled fee (1 million mojos = 0.000001 XCH) +sage:offer1qqr83wcuu2rykcmqvpsxvgqq...?fee=1000000 +``` + +### Address Links (Send XCH) + +```bash +sage:
?amount=[&fee=][&memos=] +``` + +Opens the send screen with pre-filled values. + +| Parameter | Required | Description | +| --------- | -------- | --------------------------------------------------------- | +| `address` | Yes | The destination address (xch1... or txch1...) | +| `amount` | No | Amount to send in mojos (1 XCH = 1,000,000,000,000 mojos) | +| `fee` | No | Transaction fee in mojos | +| `memos` | No | Memo text to attach to the transaction | + +**Example:** + +```bash +sage:xch1abc123...?amount=1000000000000&fee=1000000&memos=Payment%20for%20services +``` + +## Android URL Encoding Requirement + +**Important:** On Android, the `&` character in query parameters must be URL-encoded as `%26`. Android's Intent system interprets literal `&` as a command separator, which causes the URL to be truncated at the first `&`. + +| Platform | `&` (literal) | `%26` (encoded) | +| -------- | ------------- | --------------- | +| Android | URL truncated | Works | +| iOS | Works | Works | +| macOS | Works | Works | +| Windows | Works | Works | +| Linux | Works | Works | + +**For cross-platform compatibility, always use `%26` instead of `&` in deep link URLs with multiple query parameters.** + +**Correct (works everywhere):** + +``` +sage:xch1abc...?amount=1000000000000%26fee=1000000%26memos=hello +``` + +**Incorrect (fails on Android):** + +``` +sage:xch1abc...?amount=1000000000000&fee=1000000&memos=hello +``` + +The app handles both formats, but Android will truncate URLs with literal `&` before they reach the app. + +## Platform-Specific Information + +### macOS + +#### Registration + +The `sage:` URL scheme is automatically registered in the app's `Info.plist` during the build process. The Tauri deep-link plugin handles the `CFBundleURLTypes` entries automatically based on the configuration in `tauri.conf.json`. + +#### Testing + +1. **Build the app:** + + ```bash + pnpm tauri build + ``` + +2. **Install the app:** + + - Copy `src-tauri/target/release/bundle/macos/Sage.app` to `/Applications` + - Or open the `.dmg` installer and drag to Applications + +3. **Test the deep link:** + + ```bash + open "sage:offer1qqr83wcuu..." + ``` + +#### Development Limitations + +Deep links do **not** work during development with `pnpm tauri dev` on macOS. The app must be bundled and installed in `/Applications` for deep links to be recognized by the system. + +--- + +### Windows + +#### Registration + +The URL scheme is registered in the Windows Registry during app installation. The Tauri installer (`.msi` or `.exe`) handles this automatically. + +Registry entries are created at: + +- `HKEY_CURRENT_USER\Software\Classes\sage` +- Or `HKEY_LOCAL_MACHINE\Software\Classes\sage` (for all users) + +#### Testing + +1. **Build the app:** + + ```bash + pnpm tauri build + ``` + +2. **Install the app:** + + - Run the generated installer from `src-tauri/target/release/bundle/msi/` or `src-tauri/target/release/bundle/nsis/` + +3. **Test the deep link:** + + ```cmd + start sage:offer1qqr83wcuu... + ``` + + Or open the URL in a web browser. + +#### Development Testing + +On Windows, you can use `register_all()` in Rust to register the URL scheme during development without installing the app. However, this requires running with elevated permissions. + +--- + +### Linux + +#### Registration + +On Linux, the URL scheme is registered via a `.desktop` file that includes `MimeType=x-scheme-handler/sage`. This is handled automatically when: + +- Installing the `.deb` package +- Using an AppImage with an AppImage launcher + +#### Testing + +1. **Build the app:** + + ```bash + pnpm tauri build + ``` + +2. **Install the app:** + + - For `.deb`: `sudo dpkg -i src-tauri/target/release/bundle/deb/sage_*.deb` + - For AppImage: Use an AppImage launcher like [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher) + +3. **Test the deep link:** + + ```bash + xdg-open "sage:offer1qqr83wcuu..." + ``` + +#### Development Testing + +During development, you can manually create a `.desktop` file or use `xdg-mime` to register the scheme handler: + +```bash +# Create a desktop entry (replace paths appropriately) +cat > ~/.local/share/applications/sage-dev.desktop << EOF +[Desktop Entry] +Name=Sage (Dev) +Exec=/path/to/sage %u +Type=Application +MimeType=x-scheme-handler/sage; +EOF + +# Register the handler +xdg-mime default sage-dev.desktop x-scheme-handler/sage +``` + +--- + +### iOS + +#### Registration + +The URL scheme is automatically configured in the app's `Info.plist` during the build process. The Tauri plugin generates the necessary `CFBundleURLTypes` entries. + +#### Testing + +1. **Build for iOS:** + + ```bash + pnpm tauri ios build + ``` + +2. **Install on device/simulator:** + + - Use Xcode to install on a physical device or simulator + - Or use TestFlight for distribution + +3. **Test the deep link:** + + - Open Safari and navigate to `sage:offer1qqr83wcuu...` + - Or use the command line on a simulator: + + ```bash + xcrun simctl openurl booted "sage:offer1qqr83wcuu..." + ``` + +#### Development Testing + +For iOS development, you can test on the simulator or a physical device connected via Xcode. Deep links work in development builds but require the app to be properly signed. + +--- + +### Android + +#### Registration + +The URL scheme is automatically registered in the app's `AndroidManifest.xml` during the build process. The Tauri plugin adds the necessary `` with the `sage` scheme. + +The generated manifest includes: + +```xml + + + + + + +``` + +#### Testing + +1. **Build for Android:** + + ```bash + pnpm tauri android build + ``` + +2. **Install on device/emulator:** + + ```bash + adb install src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk + ``` + +3. **Test the deep link:** + + ```bash + # Offer link + adb shell am start -a android.intent.action.VIEW -d "sage:offer1qqr83wcuu..." + + # Address link with parameters (note: use %26 for &) + adb shell am start -a android.intent.action.VIEW -d "sage:xch1abc...?amount=1000000000000%26fee=1000000%26memos=hello" + ``` + +#### Development Testing + +For Android development, you can test on an emulator or physical device: + +```bash +# Start the dev server and build +pnpm tauri android dev + +# In another terminal, trigger the deep link +adb shell am start -a android.intent.action.VIEW -d "sage:offer1qqr83wcuu..." +``` + +#### URL Encoding Note + +When testing address links with multiple query parameters, remember to use `%26` instead of `&`. See [Android URL Encoding Requirement](#android-url-encoding-requirement) for details. + +--- + +## Configuration + +The deep link configuration is located in `src-tauri/tauri.conf.json`: + +```json +{ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["sage"] + }, + "mobile": [ + { + "scheme": ["sage"], + "appLink": false + } + ] + } + } +} +``` + +- **desktop.schemes**: List of URL schemes for desktop platforms (macOS, Windows, Linux) +- **mobile**: Configuration for mobile platforms (iOS, Android) + - **scheme**: List of URL schemes + - **appLink**: Set to `false` for custom schemes (no domain verification required) + +## Permissions + +The following capabilities are required: + +### Desktop (`src-tauri/capabilities/desktop.json`) + +```json +{ + "permissions": ["deep-link:default"] +} +``` + +### Mobile (`src-tauri/capabilities/mobile.json`) + +```json +{ + "permissions": ["deep-link:default"] +} +``` + +## Troubleshooting + +### Deep link not working on macOS + +- Ensure the app is installed in `/Applications` +- Verify the app was built with `pnpm tauri build`, not running in dev mode +- Check Console.app for any launch services errors + +### Deep link not working on Windows + +- Verify the app was installed via the MSI or NSIS installer +- Check the Windows Registry for the `sage` scheme under `HKEY_CURRENT_USER\Software\Classes` +- Try restarting Windows Explorer + +### Deep link not working on Linux + +- Ensure you're using an AppImage launcher or installed the `.deb` package +- Verify the MIME type is registered: `xdg-mime query default x-scheme-handler/sage` +- Check that the `.desktop` file exists in `~/.local/share/applications/` + +### Deep link not working on iOS + +- Verify the app is properly signed +- Check that the Info.plist contains the URL scheme +- Review device logs in Xcode for any errors + +### Deep link not working on Android + +- Verify the AndroidManifest.xml contains the intent filter +- Check `adb logcat` for any activity resolution errors +- Ensure no other app has registered the same scheme + +### Query parameters missing on Android + +If only the address is populated but amount, fee, or memos are missing, the URL likely contains literal `&` characters. Android's Intent system truncates URLs at the first `&`. Use `%26` instead: + +``` +# Wrong - parameters after first & will be lost +sage:xch1...?amount=100&fee=100&memos=test + +# Correct - all parameters will be received +sage:xch1...?amount=100%26fee=100%26memos=test +``` + +## References + +- [Tauri Deep Linking Plugin Documentation](https://v2.tauri.app/plugin/deep-linking/) +- [Tauri Deep Link Plugin API Reference](https://v2.tauri.app/reference/javascript/deep-link/) +- [Apple URL Scheme Documentation](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) +- [Android Deep Links Documentation](https://developer.android.com/training/app-links/deep-linking) diff --git a/package.json b/package.json index 7d0f9fc84..1fafe8a24 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tauri-apps/plugin-barcode-scanner": "^2.4.4", "@tauri-apps/plugin-biometric": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.6", "@tauri-apps/plugin-dialog": "~2.4.2", "@tauri-apps/plugin-fs": "~2.4.5", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..8cf9bb2b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@tauri-apps/plugin-clipboard-manager': specifier: ^2.3.2 version: 2.3.2 + '@tauri-apps/plugin-deep-link': + specifier: ^2.4.6 + version: 2.4.6 '@tauri-apps/plugin-dialog': specifier: ~2.4.2 version: 2.4.2 @@ -1715,6 +1718,9 @@ packages: '@tauri-apps/plugin-clipboard-manager@2.3.2': resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-deep-link@2.4.6': + resolution: {integrity: sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA==} + '@tauri-apps/plugin-dialog@2.4.2': resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} @@ -5115,6 +5121,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-deep-link@2.4.6': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-dialog@2.4.2': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d3060cf4c..3a3934eef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,12 +46,15 @@ tauri-plugin-sharesheet = "0.0.1" tauri-plugin-window-state = { workspace = true } tauri-plugin-dialog = "2" tauri-plugin-fs = "2" +tauri-plugin-deep-link = { workspace = true } +tauri-plugin-single-instance = { workspace = true, features = ["deep-link"] } [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] tauri-plugin-biometric = { workspace = true } tauri-plugin-barcode-scanner = { workspace = true } tauri-plugin-safe-area-insets = { workspace = true } tauri-plugin-sage = { workspace = true } +tauri-plugin-deep-link = { workspace = true } [build-dependencies] tauri-build = { workspace = true, features = [] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 9cc665810..ad0a8ea71 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -4,5 +4,16 @@ NSPhotoLibraryAddUsageDescription This app needs access to save images to your photo library. + CFBundleURLTypes + + + CFBundleURLName + com.rigidnetwork.sage + CFBundleURLSchemes + + sage + + + diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index bdb030db1..b23505168 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -9,6 +9,7 @@ "core:tray:default", "core:window:allow-request-user-attention", "fs:allow-write-text-file", - "dialog:default" + "dialog:default", + "deep-link:default" ] } diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 07f0f17b4..35c3cdc48 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,6 +8,7 @@ "barcode-scanner:default", "biometric:default", "sage:default", - "sharesheet:allow-share-text" + "sharesheet:allow-share-text", + "deep-link:default" ] } diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d9d5222e2..359be853c 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,19 @@ + + + + + + + + + + + + + Authenticate with biometric NSPhotoLibraryAddUsageDescription This app needs access to save images to your photo library. + CFBundleURLTypes + + + CFBundleURLName + com.rigidnetwork.sage + CFBundleURLSchemes + + sage + + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..cc00cf5c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,24 +146,33 @@ pub fn run() { // On mobile or release mode we should not export the TypeScript bindings #[cfg(all(debug_assertions, not(mobile)))] - builder - .export( - Typescript::default().bigint(BigIntExportBehavior::Number), - "../src/bindings.ts", - ) - .expect("Failed to export TypeScript bindings"); + if let Err(e) = builder.export( + Typescript::default().bigint(BigIntExportBehavior::Number), + "../src/bindings.ts", + ) { + // Don't panic - this can fail when a second instance is launched from a different directory + eprintln!("Failed to export TypeScript bindings: {e}"); + } let mut tauri_builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_os::init()); + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_deep_link::init()); #[cfg(not(mobile))] { tauri_builder = tauri_builder .plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_dialog::init()); + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + // Focus the main window when another instance is launched + // Deep link URLs are automatically forwarded by the plugin's "deep-link" feature + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + } + })); } #[cfg(mobile)] @@ -180,6 +189,18 @@ pub fn run() { .invoke_handler(builder.invoke_handler()) .setup(move |app| { builder.mount_events(app); + + // Register deep link schemes at runtime for Linux and Windows dev mode + // Linux: Always needed since schemes aren't registered via installer during dev + // Windows: Only in debug mode, requires running as Administrator first time + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + { + use tauri_plugin_deep_link::DeepLinkExt; + if let Err(e) = app.deep_link().register_all() { + eprintln!("Failed to register deep link: {e}"); + } + } + let path = app.path().app_data_dir()?; let app_state = AppState::new(Mutex::new(Sage::new(&path, false))); app.manage(Initialized(Mutex::new(false))); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9357357d1..be959d075 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,5 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Prevents console window on Windows +#![windows_subsystem = "windows"] fn main() { sage_lib::run(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c98e904ad..176da6a34 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,6 +2,19 @@ "productName": "Sage", "version": "0.12.8", "identifier": "com.rigidnetwork.sage", + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["sage"] + }, + "mobile": [ + { + "scheme": ["sage"], + "appLink": false + } + ] + } + }, "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", diff --git a/src/App.tsx b/src/App.tsx index 6fefeeff2..de1e90c02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,9 +5,11 @@ import { useEffect, useState } from 'react'; import { createHashRouter, createRoutesFromElements, + Outlet, Route, RouterProvider, } from 'react-router-dom'; +import { useDeepLink } from './hooks/useDeepLink'; import { Slide, ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { ThemeProvider, useTheme } from 'theme-o-rama'; @@ -58,6 +60,14 @@ import Transaction from './pages/Transaction'; import { Transactions } from './pages/Transactions'; import Wallet from './pages/Wallet'; +// Root layout component that handles deep linking +function RootLayout() { + // Initialize deep link handler + useDeepLink(); + + return ; +} + // Theme-aware toast container component function ThemeAwareToastContainer() { const { currentTheme } = useTheme(); @@ -89,7 +99,7 @@ function ThemeAwareToastContainer() { const router = createHashRouter( createRoutesFromElements( - <> + }> } /> } /> } /> @@ -140,7 +150,7 @@ const router = createHashRouter( } /> } /> } /> - , + , ), ); diff --git a/src/hooks/useDeepLink.ts b/src/hooks/useDeepLink.ts new file mode 100644 index 000000000..45020a968 --- /dev/null +++ b/src/hooks/useDeepLink.ts @@ -0,0 +1,205 @@ +import { useWallet } from '@/contexts/WalletContext'; +import { isValidAddress } from '@/lib/utils'; +import { t } from '@lingui/core/macro'; +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +const SCHEME_PREFIX = 'sage:'; + +interface OfferDeepLink { + type: 'offer'; + offerString: string; + fee?: string; +} + +interface AddressDeepLink { + type: 'address'; + address: string; + amount?: string; + fee?: string; + memo?: string; +} + +type DeepLinkData = OfferDeepLink | AddressDeepLink | null; + +interface ParseResult { + data: DeepLinkData; + error?: string; +} + +function decodeQueryString(queryString: string): URLSearchParams { + let decoded = queryString; + if (queryString.includes('%')) { + try { + decoded = decodeURIComponent(queryString); + } catch { + // If decoding fails, use the original string + } + } + return new URLSearchParams(decoded); +} + +function parseDeepLinkUrl(url: string): ParseResult { + if (!url.toLowerCase().startsWith(SCHEME_PREFIX)) { + return { data: null, error: 'invalid_scheme' }; + } + + const payload = url.slice(SCHEME_PREFIX.length); + + if (!payload) { + return { data: null, error: 'empty_payload' }; + } + + const [mainPart, queryString] = payload.split('?'); + + // Validate offer string: must start with offer1, be alphanumeric, and reasonable length + // Chia offers are bech32m encoded, max ~10KB when compressed + const MAX_OFFER_LENGTH = 15000; + if ( + mainPart.startsWith('offer1') && + mainPart.length <= MAX_OFFER_LENGTH && + /^[a-z0-9]+$/.test(mainPart) + ) { + const result: OfferDeepLink = { type: 'offer', offerString: mainPart }; + + if (queryString) { + const params = decodeQueryString(queryString); + const fee = params.get('fee'); + // Validate fee is a positive integer (mojos) + if (fee && /^\d+$/.test(fee)) result.fee = fee; + } + + return { data: result }; + } + + if (isValidAddress(mainPart, 'xch') || isValidAddress(mainPart, 'txch')) { + const result: AddressDeepLink = { + type: 'address', + address: mainPart, + }; + + if (queryString) { + const params = decodeQueryString(queryString); + const amount = params.get('amount'); + const fee = params.get('fee'); + const memo = params.get('memos'); + + // Validate amount and fee are positive integers (mojos) + if (amount && /^\d+$/.test(amount)) result.amount = amount; + if (fee && /^\d+$/.test(fee)) result.fee = fee; + // Memo is freeform text but limit length to prevent abuse + if (memo && memo.length <= 1000) result.memo = memo; + } + + return { data: result }; + } + + console.warn('Unrecognized deep link payload:', payload); + return { data: null, error: 'unrecognized_payload' }; +} + +/** + * Hook to handle sage: deep links on all platforms. + * Platform-specific notes: + * - macOS: Deep links only work in the bundled app installed in /Applications. + * They will not work during development with `pnpm tauri dev`. + * - Windows: Deep links are registered during app installation. + * - Linux: Requires AppImage launcher for deep links to work, or use development mode + * with register_all() in Rust. + * - iOS/Android: Deep links are configured via the mobile section in tauri.conf.json + * and work after the app is installed. + */ +export function useDeepLink() { + const navigate = useNavigate(); + const { wallet } = useWallet(); + + // Use refs so the effect doesn't re-run when these change + const walletRef = useRef(wallet); + const navigateRef = useRef(navigate); + + // Keep refs up to date + useEffect(() => { + walletRef.current = wallet; + navigateRef.current = navigate; + }, [wallet, navigate]); + + useEffect(() => { + let cleanup: (() => void) | null = null; + let isMounted = true; + + const handleDeepLinkUrls = (urls: string[]) => { + for (const url of urls) { + // Parse and validate URL first before checking wallet + const { data: deepLinkData, error } = parseDeepLinkUrl(url); + if (!deepLinkData) { + if (error) { + toast.error(t`Invalid deep link`); + } + continue; + } + + // Only check wallet for valid deep links + if (!walletRef.current) { + toast.error(t`Please log into a wallet first`); + return; + } + + if (deepLinkData.type === 'offer') { + let offerUrl = `/offers/view/${encodeURIComponent(deepLinkData.offerString)}`; + if (deepLinkData.fee) { + offerUrl += `?fee=${encodeURIComponent(deepLinkData.fee)}`; + } + navigateRef.current(offerUrl); + break; + } + + if (deepLinkData.type === 'address') { + const params = new URLSearchParams(); + params.set('address', deepLinkData.address); + if (deepLinkData.amount) params.set('amount', deepLinkData.amount); + if (deepLinkData.fee) params.set('fee', deepLinkData.fee); + if (deepLinkData.memo) params.set('memo', deepLinkData.memo); + + navigateRef.current(`/wallet/send/xch?${params.toString()}`); + break; + } + } + }; + + const initDeepLink = async () => { + try { + const { getCurrent, onOpenUrl } = await import( + '@tauri-apps/plugin-deep-link' + ); + + if (!isMounted) return; + + // Check if app was launched via deep link + const initialUrls = await getCurrent(); + if (initialUrls && initialUrls.length > 0) { + handleDeepLinkUrls(initialUrls); + } + + if (!isMounted) return; + + // Listen for deep link events while the app is running + // The single-instance plugin with "deep-link" feature automatically forwards URLs here + cleanup = await onOpenUrl(handleDeepLinkUrls); + } catch (error) { + // This can happen if the plugin isn't available on the current platform + // or if there's a configuration issue. Log but don't crash. + console.warn('Deep link handler not available:', error); + } + }; + + initDeepLink(); + + return () => { + isMounted = false; + if (cleanup) { + cleanup(); + } + }; + }, []); // Empty deps - only run once +} diff --git a/src/pages/Offer.tsx b/src/pages/Offer.tsx index f71ae8f65..28e3b65e4 100644 --- a/src/pages/Offer.tsx +++ b/src/pages/Offer.tsx @@ -16,18 +16,19 @@ import { FeeAmountInput } from '@/components/ui/masked-input'; import { CustomError } from '@/contexts/ErrorContext'; import { useErrors } from '@/hooks/useErrors'; import { resolveOfferData } from '@/lib/offerData'; -import { toMojos } from '@/lib/utils'; +import { fromMojos, toMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { useCallback, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; export function Offer() { const { offer } = useParams(); const { addError } = useErrors(); const walletState = useWalletState(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [isLoading, setIsLoading] = useState(true); const [loadingStatus, setLoadingStatus] = useState(t`Initializing...`); @@ -37,6 +38,15 @@ export function Offer() { const [fee, setFee] = useState(''); const [resolvedOffer, setResolvedOffer] = useState(null); + // Populate fee from URL query parameters (e.g., from deep links) + useEffect(() => { + const feeMojos = searchParams.get('fee'); + if (feeMojos) { + const feeDecimal = fromMojos(feeMojos, walletState.sync.unit.precision); + setFee(feeDecimal.toString()); + } + }, [searchParams, walletState.sync.unit.precision]); + const resolveOffer = useCallback(async () => { if (!offer) return; @@ -127,6 +137,7 @@ export function Offer() { setFee(values.value)} onKeyDown={(event) => { if (event.key === 'Enter') { diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx index 49dce50b1..4c0fad05d 100644 --- a/src/pages/Send.tsx +++ b/src/pages/Send.tsx @@ -43,7 +43,7 @@ import BigNumber from 'bignumber.js'; import { AlertCircleIcon, ArrowUpToLine } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import * as z from 'zod'; import { commands, @@ -65,6 +65,7 @@ export default function Send() { const navigate = useNavigate(); const walletState = useWalletState(); + const [searchParams] = useSearchParams(); const [asset, setAsset] = useState(null); const [response, setResponse] = useState(null); @@ -131,7 +132,7 @@ export default function Send() { asset ? BigNumber(amount).lte(toDecimal(asset.balance, asset.precision)) : true, - 'Amount exceeds balance', + t`Amount exceeds balance`, ), fee: amount(walletState.sync.unit.precision).optional(), memo: z.string().optional(), @@ -149,6 +150,30 @@ export default function Send() { resolver: zodResolver(formSchema), }); + // Populate form from URL query parameters (e.g., from deep links) + useEffect(() => { + const address = searchParams.get('address'); + const amountMojos = searchParams.get('amount'); + const feeMojos = searchParams.get('fee'); + const memo = searchParams.get('memo'); + + if (address) { + form.setValue('address', address); + } + if (amountMojos) { + const precision = asset?.precision ?? 12; + const amountDecimal = fromMojos(amountMojos, precision); + form.setValue('amount', amountDecimal.toString()); + } + if (feeMojos) { + const feeDecimal = fromMojos(feeMojos, walletState.sync.unit.precision); + form.setValue('fee', feeDecimal.toString()); + } + if (memo) { + form.setValue('memo', memo); + } + }, [searchParams, asset?.precision, walletState.sync.unit.precision, form]); + const { handleScanOrPaste } = useScannerOrClipboard((scanResValue) => { form.setValue('address', scanResValue); });