commit 9ad64ec98790846f2ae829e2836f2677809acded Author: upstream-mirror Date: Wed Sep 23 17:17:10 2026 +0500 snapshot of https://github.com/tauri-apps/plugins-workspace.git (v2, 2026-09-23; blobs > 131072B omitted) diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..de84e84 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,20 @@ +[advisories] +ignore = [ + # rsa Marvin Attack + "RUSTSEC-2023-0071", + # time crate can't be updated in the repo because of MSRV, users are unaffected + "RUSTSEC-2026-0009", + # libflate crates can't be updated in the repo because of MSRV, users are unaffected + "RUSTSEC-2026-0105", + # quick-xml, need an update in plist, can't be updated in the repo because of MSRV, users are unaffected + # Also needs on update in wayland-scanner > arboard > tauri-plugin-clipboard-manager + "RUSTSEC-2026-0194", + # quick-xml, need an update in plist, can't be updated in the repo because of MSRV, users are unaffected + # Also needs on update in wayland-scanner > arboard > tauri-plugin-clipboard-manager + "RUSTSEC-2026-0195", + # quinn-proto, can't be updated in the repo because of MSRV, users are unaffected + "RUSTSEC-2026-0185", + # rkyv is not used, it's brought in by `rust_decimal`'s `rkyv` feature to the lockfile, + # but that feature is not enabled by anything + "RUSTSEC-2026-0235", +] diff --git a/.changes/config.json b/.changes/config.json new file mode 100644 index 0000000..93ed46e --- /dev/null +++ b/.changes/config.json @@ -0,0 +1,355 @@ +{ + "gitSiteUrl": "https://github.com/tauri-apps/plugins-workspace/", + "pkgManagers": { + "javascript": { + "version": true, + "getPublishedVersion": { + "use": "fetch:check", + "options": { + "url": "https://registry.npmjs.com/${ pkg.pkgFile.pkg.name }/${ pkg.pkgFile.version }" + } + }, + "publish": [ + { + "command": "pnpm build", + "dryRunCommand": "pnpm build" + }, + { + "command": "echo '
\n

PNPM Publish

\n\n```'", + "dryRunCommand": true, + "pipe": true + }, + { + "command": "npm publish --provenance --access public", + "dryRunCommand": "npm publish --provenance --access public --dry-run", + "pipe": true + }, + { + "command": "echo '```\n\n
\n'", + "dryRunCommand": true, + "pipe": true + } + ] + }, + "rust": { + "version": true, + "getPublishedVersion": { + "use": "fetch:check", + "options": { + "url": "https://crates.io/api/v1/crates/${ pkg.pkgFile.pkg.package.name }/${ pkg.pkgFile.version }" + } + }, + "publish": [ + { + "command": "echo '
\n

Cargo Publish

\n\n```'", + "dryRunCommand": true, + "pipe": true + }, + { + "command": "cargo publish --no-verify", + "dryRunCommand": "cargo publish --dry-run", + "pipe": true + }, + { + "command": "echo '```\n\n
\n'", + "dryRunCommand": true, + "pipe": true + } + ] + } + }, + "packages": { + "api-example": { + "path": "./examples/api/src-tauri", + "manager": "rust", + "publish": false, + "dependencies": [ + "barcode-scanner", + "biometric", + "log", + "cli", + "clipboard-manager", + "dialog", + "fs", + "global-shortcut", + "opener", + "http", + "nfc", + "notification", + "os", + "process", + "shell", + "store", + "updater", + "geolocation", + "haptics" + ] + }, + "api-example-js": { + "path": "./examples/api", + "manager": "javascript", + "publish": false, + "dependencies": [ + "barcode-scanner-js", + "biometric-js", + "log-js", + "cli-js", + "clipboard-manager-js", + "dialog-js", + "fs-js", + "global-shortcut-js", + "opener-js", + "http-js", + "nfc-js", + "notification-js", + "os-js", + "process-js", + "shell-js", + "store-js", + "updater-js" + ], + "postversion": "pnpm install --no-frozen-lockfile" + }, + "deep-link-example-js": { + "path": "./plugins/deep-link/examples/app", + "manager": "javascript", + "publish": false, + "dependencies": ["deep-link-js"], + "postversion": "pnpm install --no-frozen-lockfile" + }, + "autostart": { + "path": "./plugins/autostart", + "manager": "rust" + }, + "autostart-js": { + "path": "./plugins/autostart", + "manager": "javascript" + }, + "barcode-scanner": { + "path": "./plugins/barcode-scanner", + "manager": "rust" + }, + "barcode-scanner-js": { + "path": "./plugins/barcode-scanner", + "manager": "javascript" + }, + "biometric": { + "path": "./plugins/biometric", + "manager": "rust" + }, + "biometric-js": { + "path": "./plugins/biometric", + "manager": "javascript" + }, + "cli": { + "path": "./plugins/cli", + "manager": "rust" + }, + "cli-js": { + "path": "./plugins/cli", + "manager": "javascript" + }, + "clipboard-manager": { + "path": "./plugins/clipboard-manager", + "manager": "rust" + }, + "clipboard-manager-js": { + "path": "./plugins/clipboard-manager", + "manager": "javascript" + }, + "deep-link": { + "path": "./plugins/deep-link", + "manager": "rust" + }, + "deep-link-js": { + "path": "./plugins/deep-link", + "manager": "javascript" + }, + "fs": { + "path": "./plugins/fs", + "manager": "rust" + }, + "fs-js": { + "path": "./plugins/fs", + "manager": "javascript" + }, + "dialog": { + "path": "./plugins/dialog", + "manager": "rust", + "dependencies": ["fs"] + }, + "dialog-js": { + "path": "./plugins/dialog", + "manager": "javascript", + "dependencies": ["fs-js"] + }, + "geolocation": { + "path": "./plugins/geolocation", + "manager": "rust" + }, + "geolocation-js": { + "path": "./plugins/geolocation", + "manager": "javascript" + }, + "global-shortcut": { + "path": "./plugins/global-shortcut", + "manager": "rust" + }, + "global-shortcut-js": { + "path": "./plugins/global-shortcut", + "manager": "javascript" + }, + "opener": { + "path": "./plugins/opener", + "manager": "rust" + }, + "opener-js": { + "path": "./plugins/opener", + "manager": "javascript" + }, + "haptics": { + "path": "./plugins/haptics", + "manager": "rust" + }, + "haptics-js": { + "path": "./plugins/haptics", + "manager": "javascript" + }, + "http": { + "path": "./plugins/http", + "manager": "rust", + "dependencies": ["fs"] + }, + "http-js": { + "path": "./plugins/http", + "manager": "javascript", + "dependencies": ["fs-js"] + }, + "localhost": { + "path": "./plugins/localhost", + "manager": "rust" + }, + "log": { + "path": "./plugins/log", + "manager": "rust" + }, + "log-js": { + "path": "./plugins/log", + "manager": "javascript" + }, + "nfc": { + "path": "./plugins/nfc", + "manager": "rust" + }, + "nfc-js": { + "path": "./plugins/nfc", + "manager": "javascript" + }, + "notification": { + "path": "./plugins/notification", + "manager": "rust" + }, + "notification-js": { + "path": "./plugins/notification", + "manager": "javascript" + }, + "os": { + "path": "./plugins/os", + "manager": "rust" + }, + "os-js": { + "path": "./plugins/os", + "manager": "javascript" + }, + "persisted-scope": { + "path": "./plugins/persisted-scope", + "manager": "rust", + "dependencies": ["fs"] + }, + "positioner": { + "path": "./plugins/positioner", + "manager": "rust" + }, + "positioner-js": { + "path": "./plugins/positioner", + "manager": "javascript" + }, + "process": { + "path": "./plugins/process", + "manager": "rust" + }, + "process-js": { + "path": "./plugins/process", + "manager": "javascript" + }, + "shell": { + "path": "./plugins/shell", + "manager": "rust" + }, + "shell-js": { + "path": "./plugins/shell", + "manager": "javascript" + }, + "single-instance": { + "path": "./plugins/single-instance", + "manager": "rust", + "dependencies": ["deep-link"] + }, + "sql": { + "path": "./plugins/sql", + "manager": "rust" + }, + "sql-js": { + "path": "./plugins/sql", + "manager": "javascript" + }, + "store": { + "path": "./plugins/store", + "manager": "rust" + }, + "store-js": { + "path": "./plugins/store", + "manager": "javascript" + }, + "stronghold": { + "path": "./plugins/stronghold", + "manager": "rust" + }, + "stronghold-js": { + "path": "./plugins/stronghold", + "manager": "javascript" + }, + "updater": { + "path": "./plugins/updater", + "manager": "rust" + }, + "updater-js": { + "path": "./plugins/updater", + "manager": "javascript" + }, + "upload": { + "path": "./plugins/upload", + "manager": "rust" + }, + "upload-js": { + "path": "./plugins/upload", + "manager": "javascript" + }, + "websocket": { + "path": "./plugins/websocket", + "manager": "rust" + }, + "websocket-js": { + "path": "./plugins/websocket", + "manager": "javascript" + }, + "window-state": { + "path": "./plugins/window-state", + "manager": "rust" + }, + "window-state-js": { + "path": "./plugins/window-state", + "manager": "javascript" + } + } +} diff --git a/.changes/fix-autostart-windows-disable.md b/.changes/fix-autostart-windows-disable.md new file mode 100644 index 0000000..1bae668 --- /dev/null +++ b/.changes/fix-autostart-windows-disable.md @@ -0,0 +1,5 @@ +--- +autostart: patch +--- + +Fixed `disable()` failing on Windows when autostart is already disabled. It now succeeds, as it does on macOS and Linux. diff --git a/.changes/fix-deep-link-linux-unregister.md b/.changes/fix-deep-link-linux-unregister.md new file mode 100644 index 0000000..ab542f7 --- /dev/null +++ b/.changes/fix-deep-link-linux-unregister.md @@ -0,0 +1,5 @@ +--- +deep-link: patch +--- + +Fixed `unregister` on Linux leaving the app as the scheme's handler: it now also removes the scheme from the `MimeType` of the handler's `.desktop` file and refreshes the desktop database, which `xdg-mime` falls back to. It also no longer fails when `mimeapps.list` does not exist. diff --git a/.changes/fix-geo-haptics-specta-skip.md b/.changes/fix-geo-haptics-specta-skip.md new file mode 100644 index 0000000..78f11d5 --- /dev/null +++ b/.changes/fix-geo-haptics-specta-skip.md @@ -0,0 +1,8 @@ +--- +"haptics": patch +"geolocation": patch +"haptics-js": patch +"geolocation-js": patch +--- + +Fixed a build failure when the `specta` feature is enabled: the `Error` type used the `#[serde(skip)]` helper attribute, which recent `specta` versions no longer register. It now uses `#[specta(skip)]`. diff --git a/.changes/fix-notification-mobile-permissions.md b/.changes/fix-notification-mobile-permissions.md new file mode 100644 index 0000000..8a8174a --- /dev/null +++ b/.changes/fix-notification-mobile-permissions.md @@ -0,0 +1,11 @@ +--- +notification: patch +notification-js: patch +--- + +Fixed broken mobile APIs: + +- `channels()` invoked the `listChannels` command, which the `allow-list-channels` permission does not match; it now invokes `list_channels`. +- Unregistering an `onNotificationReceived` or `onAction` listener invokes the `remove_listener` command, which had no permission. It is now declared and part of the `default` permission set (`allow-remove-listener`). +- `cancelAll()` was rejected on Android and iOS, which required the list of notifications to cancel. It now cancels every pending notification. +- `active()` resolved with `{ values: [...] }` instead of an array on Android. diff --git a/.changes/fix-notification-windows-permission.md b/.changes/fix-notification-windows-permission.md new file mode 100644 index 0000000..8dc1a40 --- /dev/null +++ b/.changes/fix-notification-windows-permission.md @@ -0,0 +1,6 @@ +--- +notification: patch +notification-js: patch +--- + +Fixed `isPermissionGranted()` always resolving to `false` on Windows. The initialization script short-circuited the permission check to avoid invoking the backend, but read the permission back before it had been set, so it settled on `denied` instead of `granted`. diff --git a/.changes/global-api-script-reuse-core.md b/.changes/global-api-script-reuse-core.md new file mode 100644 index 0000000..7fd096b --- /dev/null +++ b/.changes/global-api-script-reuse-core.md @@ -0,0 +1,58 @@ +--- +"autostart": patch +"autostart-js": patch +"barcode-scanner": patch +"barcode-scanner-js": patch +"biometric": patch +"biometric-js": patch +"cli": patch +"cli-js": patch +"clipboard-manager": patch +"clipboard-manager-js": patch +"deep-link": patch +"deep-link-js": patch +"dialog": patch +"dialog-js": patch +"fs": patch +"fs-js": patch +"geolocation": patch +"geolocation-js": patch +"global-shortcut": patch +"global-shortcut-js": patch +"haptics": patch +"haptics-js": patch +"http": patch +"http-js": patch +"log": patch +"log-js": patch +"nfc": patch +"nfc-js": patch +"notification": patch +"notification-js": patch +"opener": patch +"opener-js": patch +"os": patch +"os-js": patch +"positioner": patch +"positioner-js": patch +"process": patch +"process-js": patch +"shell": patch +"shell-js": patch +"sql": patch +"sql-js": patch +"store": patch +"store-js": patch +"stronghold": patch +"stronghold-js": patch +"updater": patch +"updater-js": patch +"upload": patch +"upload-js": patch +"websocket": patch +"websocket-js": patch +"window-state": patch +"window-state-js": patch +--- + +The plugin's global API script (used with `app.withGlobalTauri`) now resolves the core API from `window.__TAURI__` instead of bundling its own copy of `@tauri-apps/api`. Values created with the core API are now accepted by plugin APIs in global mode (e.g. an `Image` from `window.__TAURI__.image` passed to `clipboardManager.writeImage`, which previously failed the `instanceof` check against the plugin's private copy), and the script is considerably smaller. diff --git a/.changes/readme.md b/.changes/readme.md new file mode 100644 index 0000000..6d9396b --- /dev/null +++ b/.changes/readme.md @@ -0,0 +1,32 @@ +# Changes + +##### via https://github.com/jbolda/covector + +As you create PRs and make changes that require a version bump, please add a new markdown file in this folder. You do not note the version _number_, but rather the type of bump that you expect: major, minor, or patch. The filename is not important, as long as it is a `.md`, but we recommend that it represents the overall change for organizational purposes. + +When you select the version bump required, you do _not_ need to consider dependencies. Only note the package with the actual change, and any packages that depend on that package will be bumped automatically in the process. + +**Note, that in this repository, even if only the Rust code or only the JavaScript code of a plugin changed, both packages need to be bumped with the same increment!** + +Use the following format: + +```md +--- +"package-a": patch +"package-b": minor:feat +--- + +Change summary goes here +``` + +Summaries do not have a specific character limit, but are text only. These summaries are used within the (future implementation of) changelogs. They will give context to the change and also point back to the original PR if more details and context are needed. + +Changes will be designated as a `major`, `minor` or `patch` as further described in [semver](https://semver.org/). + +Given a version number MAJOR.MINOR.PATCH, increment the: + +- MAJOR version when you make incompatible API changes, +- MINOR version when you add functionality in a backwards compatible manner, and +- PATCH version when you make backwards compatible bug fixes. + +Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format, but will be discussed prior to usage (as extra steps will be necessary in consideration of merging and publishing). diff --git a/.changes/update-docs.md b/.changes/update-docs.md new file mode 100644 index 0000000..1d491b1 --- /dev/null +++ b/.changes/update-docs.md @@ -0,0 +1,61 @@ +--- +"autostart": patch +"barcode-scanner": patch +"biometric": patch +"cli": patch +"clipboard-manager": patch +"deep-link": patch +"dialog": patch +"fs": patch +"global-shortcut": patch +"http": patch +"localhost": patch +"log": patch +"nfc": patch +"notification": patch +"opener": patch +"os": patch +"persisted-scope": patch +"positioner": patch +"process": patch +"shell": patch +"single-instance": patch +"sql": patch +"store": patch +"stronghold": patch +"updater": patch +"upload": patch +"websocket": patch +"window-state": patch +"autostart-js": patch +"barcode-scanner-js": patch +"biometric-js": patch +"cli-js": patch +"clipboard-manager-js": patch +"deep-link-js": patch +"dialog-js": patch +"fs-js": patch +"global-shortcut-js": patch +"http-js": patch +"log-js": patch +"nfc-js": patch +"notification-js": patch +"opener-js": patch +"os-js": patch +"positioner-js": patch +"process-js": patch +"shell-js": patch +"sql-js": patch +"store-js": patch +"stronghold-js": patch +"updater-js": patch +"upload-js": patch +"websocket-js": patch +"window-state-js": patch +"haptics": patch +"haptics-js": patch +"geolocation": patch +"geolocation-js": patch +--- + +Update documentation. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..83ba0c4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Order is important; the last matching pattern takes the most precedence. +* @tauri-apps/plugin-maintainers + +# Currently CI/CD for plugins are in heavy flux, and the plugin team manages it themselves. +# .github @tauri-apps/wg-devops diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..a2ba06f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Tauri Contributing Guide + +Hi! We, the maintainers, are really excited that you are interested in contributing to Tauri. Before submitting your contribution though, please make sure to take a moment and read through the [Code of Conduct](CODE_OF_CONDUCT.md), as well as the appropriate section for the contribution you intend to make: + +- [Issue Reporting Guidelines](#issue-reporting-guidelines) +- [Pull Request Guidelines](#pull-request-guidelines) +- [Development Guide](#development-guide) +- [AI Tool Policy](#ai-tool-policy) + +## Issue Reporting Guidelines + +- The issue list of this repo is **exclusively** for bug reports and feature requests. Non-conforming issues will be closed immediately. + +- If you have a question, you can get quick answers from the [Tauri Discord chat](https://discord.com/invite/tauri). + +- Try to search for your issue, it may have already been answered or even fixed in the development branch (`v2`). + +- Check if the issue is reproducible with the latest stable version of Tauri. If you are using a pre-release, please indicate the specific version you are using. + +- It is **required** that you clearly describe the steps necessary to reproduce the issue you are running into. Although we would love to help our users as much as possible, diagnosing issues without clear reproduction steps is extremely time-consuming and simply not sustainable. + +- Use only the minimum amount of code necessary to reproduce the unexpected behavior. A good bug report should isolate specific methods that exhibit unexpected behavior and precisely define how expectations were violated. What did you expect the method or methods to do, and how did the observed behavior differ? The more precisely you isolate the issue, the faster we can investigate. + +- Issues with no clear repro steps will not be triaged. If an issue labeled "need repro" receives no further input from the issue author for more than 5 days, it will be closed. + +- If your issue is resolved but still open, don't hesitate to close it. In case you found a solution by yourself, it could be helpful for others to explain how you fixed it. + +- Most importantly, we beg your patience: the team must balance your request against many other responsibilities — fixing other bugs, answering other questions, new features, new documentation, etc. The issue list is not paid support and we cannot make guarantees about how fast your issue can be resolved. + +## Pull Request Guidelines + +- You have to [sign your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). + +- It's OK to have multiple small commits as you work on the PR - we will let GitHub automatically squash it before merging. + +- If adding new feature: + - Provide convincing reason to add this feature. Ideally you should open a suggestion issue first and have it greenlighted before working on it. + +- If fixing a bug: + - If you are resolving a special issue, add `(fix: #xxxx[,#xxx])` (#xxxx is the issue id) in your PR title for a better release log, e.g. `fix: update entities encoding/decoding (fix #3899)`. + - Provide detailed description of the bug in the PR, or link to an issue that does. + +- If the PR is meant to be released, follow the instructions in `.changes/readme.md` to log your changes. ie. [readme.md](https://github.com/tauri-apps/plugins-workspace/blob/v2/.changes/readme.md) + +## Development Guide + +**NOTE: If you have any question don't hesitate to ask in our Discord server. We try to keep this guide to up guide, but if something doesn't work let us know.** + +### General Setup + +First, [join our Discord server](https://discord.com/invite/tauri) and let us know that you want to contribute. This way we can point you in the right direction and help ensure your contribution will be as helpful as possible. + +To set up your machine for development, follow the [Tauri setup guide](https://v2.tauri.app/start/prerequisites/) to get all the tools you need to develop Tauri apps. The only additional tool you may need is [PNPM](https://pnpm.io/). + +Next, [fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) and clone [this repository](https://github.com/tauri-apps/plugins-workspace/tree/v2). + +### Developing and testing + +The easiest way to test your changes is to use the [example app](https://github.com/tauri-apps/plugins-workspace/tree/v2/examples/api) example app. It automatically rebuilds and uses your local copy of the plugins. To run it follow the instructions inside [README.md](https://github.com/tauri-apps/plugins-workspace/blob/v2/examples/api/README.md). + +To test local changes against your own application simply point the plugin create to your local repository, for example: + +`tauri-plugin-sample = { path = "path/to/local/tauri-plugin-sample/" }` + +## AI Tool Policy + +It takes a lot of time to review a Pull Request while it's very easy to make a nonsensical but plausible looking one using AI tools. +It is unfair for other contributors and the reviewers to spend much of the time dealing with this, hence these rules: + +1. Review and test all LLM-generated content before submitting, you're the one responsible for it, not the AI. +2. Don't use AI to respond to review comments (except for translations). + +We will close the Pull Request with a `ai-slop` tag if you failed to do so. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2e13004 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,63 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: 🐞 Bug Report +description: Report a bug +labels: ['type: bug'] + +body: + - type: markdown + attributes: + value: | + ## First of all + 1. Please search for [existing issues](https://github.com/tauri-apps/plugins-workspace/issues?q=is%3Aissue) about this problem first. + 2. Make sure `rustc` and all relevant Tauri packages are up to date. + 3. Make sure it's an issue with a tauri plugin and not something else you are using. + 4. Remember to follow our community guidelines and be friendly. + + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear description of what the bug is. Include screenshots if applicable. + placeholder: Bug description + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: A link to a reproduction repo or steps to reproduce the behaviour. + placeholder: | + Please provide a minimal reproduction or steps to reproduce, see this guide https://stackoverflow.com/help/minimal-reproducible-example + Why reproduction is required? see this article https://antfu.me/posts/why-reproductions-are-required + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: A clear description of what you expected to happen. + + - type: textarea + id: info + attributes: + label: Full `tauri info` output + description: 'Output of `npm run tauri info` or `cargo tauri info`. Issues without this will be closed!' + render: text + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Stack trace + description: A stack trace or ANY error messages you may see. + render: text + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem here. For example a link to a Discord discussion. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f19ffc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +#blank_issues_enabled: false +contact_links: + - name: 💡 Request a new plugin + url: https://github.com/orgs/tauri-apps/discussions/new?category=plugin-requests + about: Propose a new Plugin to the community or the Tauri org. + - name: 💬 Discord Chat + url: https://discord.com/invite/tauri + about: Ask questions and talk to other Tauri users and the maintainers diff --git a/.github/ISSUE_TEMPLATE/docs_report.md b/.github/ISSUE_TEMPLATE/docs_report.md new file mode 100644 index 0000000..267c967 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_report.md @@ -0,0 +1,5 @@ +--- +name: 📚 Docs Report +about: Create a report to help us improve the docs +labels: 'type: documentation' +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3e9f403 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: 💡 Feature Request +description: Request a feature or enhancement for an existing plugin +labels: ['type: feature request'] + +body: + - type: markdown + attributes: + value: | + ## First of all + - Please search for [existing issues](https://github.com/tauri-apps/plugins-workspace/issues?q=is%3Aissue) for this request first. + - Only requests for plugins that exist in this repo are allowed. + - You can request new plugins [here](https://github.com/orgs/tauri-apps/discussions/new?category=plugin-requests) + + - type: textarea + id: problem + attributes: + label: Describe the problem + description: A clear description of the problem this feature would solve + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: "Describe the solution you'd like" + description: A clear description of what change you would like + placeholder: 'I would like to...' + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: "Any alternative solutions you've considered" + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem here. diff --git a/.github/sponsors/crabnebula.svg b/.github/sponsors/crabnebula.svg new file mode 100644 index 0000000..40e2413 --- /dev/null +++ b/.github/sponsors/crabnebula.svg @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/.github/sponsors/rescue.png b/.github/sponsors/rescue.png new file mode 100644 index 0000000..2b5916f Binary files /dev/null and b/.github/sponsors/rescue.png differ diff --git a/.github/sync-to-mirrors.sh b/.github/sync-to-mirrors.sh new file mode 100755 index 0000000..4baf55d --- /dev/null +++ b/.github/sync-to-mirrors.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +## Environment used by this script: +# +# Current directory must be within the repo being mirrored from, so the commit message can be read. +# +# Required: +# - BUILD_BASE: Path to the build directory, which contains "mirrors.txt" and directories for each repo to mirror to. +# - GITHUB_ACTOR: GitHub username for the commit being mirrored. +# - GITHUB_REF: Git ref being mirrored from, e.g. "refs/heads/main". Must begin with "refs/heads/". +# +# Other: +# - API_TOKEN_GITHUB: Personal access token to use when accessing GitHub. +# - CI: If unset or empty, the commits will be prepared but the actual push will not happen. +# - COMMIT_MESSAGE: Commit message to use for the mirror commits. Will be read from HEAD in `SOURCE_DIR` if not specified. +# - GITHUB_REPOSITORY: GH repository, used in the commit message if `COMMIT_MESSAGE` is not specified. +# - GITHUB_RUN_ID: GH Actions run ID, used in the commit message if `COMMIT_MESSAGE` is not specified. +# - GITHUB_SHA: Head SHA1 from which to fetch the commit message for the commit being mirrored. HEAD will be assumed if not specified. +# - SOURCE_DIR: Source directory, used when `COMMIT_MESSAGE` is not specified. + +# Halt on error +set -eo pipefail + +if [[ -n "$CI" ]]; then + export GIT_AUTHOR_NAME="tauri-bot" + export GIT_AUTHOR_EMAIL="tauri-bot@users.noreply.github.com" + export GIT_COMMITTER_NAME="tauri-bot" + export GIT_COMMITTER_EMAIL="tauri-bot@users.noreply.github.com" +fi + +if [[ -z "$BUILD_BASE" ]]; then + echo "::error::BUILD_BASE must be set" + exit 1 +elif [[ ! -d "$BUILD_BASE" ]]; then + echo "::error::$BUILD_BASE does not exist or is not a directory" + exit 1 +fi + +if [[ -z "$COMMIT_MESSAGE" ]]; then + MONOREPO_COMMIT_MESSAGE=$(cd "${SOURCE_DIR:-.}" && git show -s --format=%B $GITHUB_SHA) + COMMIT_MESSAGE=$( printf "%s\n\nCommitted via a GitHub action: https://github.com/%s/actions/runs/%s" "$MONOREPO_COMMIT_MESSAGE" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" ) +fi +COMMIT_ACTOR="${GITHUB_ACTOR} <${GITHUB_ACTOR}@users.noreply.github.com>" +COMMIT_AUTHOR=$(cd "${SOURCE_DIR:-.}" &&git show -s --format="%an <%ae>" $GITHUB_SHA) + +if [[ "$GITHUB_REF" =~ ^refs/heads/ ]]; then + BRANCH=${GITHUB_REF#refs/heads/} +else + echo "::error::Could not determine branch name from $GITHUB_REF" + exit 1 +fi + +if [[ ! -f "$BUILD_BASE/mirrors.txt" ]]; then + echo "::error::File $BUILD_BASE/mirrors.txt does not exist or is not a file" + exit 1 +elif [[ ! -s "$BUILD_BASE/mirrors.txt" ]]; then + echo "Nothing to do, $BUILD_BASE/mirrors.txt is empty." + exit 0 +fi + +# : > "$BUILD_BASE/changes.diff" + +# Collect tags of current commit +readarray -t COMMIT_TAGS < <(git tag --points-at HEAD) + +EXIT=0 +while read -r PLUGIN_NAME; do + printf "\n\n\e[7m Mirror: %s \e[0m\n" "$PLUGIN_NAME" + CLONE_DIR="${BUILD_BASE}/${PLUGIN_NAME}" + cd "${CLONE_DIR}" + + # Initialize the directory as a git repo, and set the remote + git init -b "$BRANCH" . + git remote add origin "https://github.com/tauri-apps/tauri-plugin-${PLUGIN_NAME}" + if [[ -n "$API_TOKEN_GITHUB" ]]; then + git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $(printf "x-access-token:%s" "$API_TOKEN_GITHUB" | base64)" + fi + + # Check if a remote exists for that mirror. + if ! git ls-remote -h origin >/dev/null 2>&1; then + echo "::error::Mirror repo for ${PLUGIN_NAME} does not exist." + echo "Skipping." + EXIT=1 + continue + fi + + echo "::group::Fetching ${PLUGIN_NAME}" + FORCE_COMMIT= + if git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin "$BRANCH"; then + git reset --soft FETCH_HEAD + echo "Fetched revision $(git rev-parse HEAD)" + elif [[ -n "$DEFAULT_BRANCH" ]] && git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin "$DEFAULT_BRANCH"; then + FORCE_COMMIT=--allow-empty + git reset --soft FETCH_HEAD + echo "Fetched revision $(git rev-parse HEAD)" + else + echo "Failed to find a branch to branch from, just creating an empty one." + FORCE_COMMIT=--allow-empty + fi + git add -A + echo "::endgroup::" + + if [[ -n "$FORCE_COMMIT" || -n "$(git status --porcelain)" ]]; then + echo "Committing to $PLUGIN_NAME" + GIT_CLI_COMMIT_MESSAGE=$( printf "%s \n\nCo-authored-by: %s" "$COMMIT_MESSAGE" "$COMMIT_ACTOR" ) + if git commit $FORCE_COMMIT --author="${COMMIT_AUTHOR}" -m "${GIT_CLI_COMMIT_MESSAGE}" && + { [[ -z "$CI" ]] || git push origin "$BRANCH"; } # Only do the actual push from the GitHub Action + then + # echo "$BUILD_BASE/changes.diff" + # git show --pretty= --src-prefix="a/$PLUGIN_NAME/" --dst-prefix="b/$PLUGIN_NAME/" >> "$BUILD_BASE/changes.diff" + echo "https://github.com/tauri-apps/tauri-plugin-$PLUGIN_NAME/commit/$(git rev-parse HEAD)" + + # Add new tags + for FULL_TAG in "${COMMIT_TAGS[@]}"; do + if [[ "$FULL_TAG" =~ ^"$PLUGIN_NAME-js-v" ]]; then + TAG_NAME="${FULL_TAG#"$PLUGIN_NAME-js-"}" + echo "Creating tag $TAG_NAME" + git tag "${TAG_NAME}" -m "${GIT_CLI_COMMIT_MESSAGE}" + git push origin "${TAG_NAME}" + fi + done + + echo "Completed $PLUGIN_NAME" + else + echo "::error::Commit of ${PLUGIN_NAME} failed" + EXIT=1 + fi + else + echo "No changes, skipping $PLUGIN_NAME" + fi +done < "$BUILD_BASE/mirrors.txt" + +exit $EXIT diff --git a/.github/workflows/audit-javascript.yml b/.github/workflows/audit-javascript.yml new file mode 100644 index 0000000..151d913 --- /dev/null +++ b/.github/workflows/audit-javascript.yml @@ -0,0 +1,49 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Audit JavaScript + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/audit-javascript.yml' + - '**/pnpm-lock.yaml' + - '**/package.json' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/audit-javascript.yml' + - '**/pnpm-lock.yaml' + - '**/package.json' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + audit-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + - name: audit + run: pnpm audit diff --git a/.github/workflows/audit-rust.yml b/.github/workflows/audit-rust.yml new file mode 100644 index 0000000..cba74ae --- /dev/null +++ b/.github/workflows/audit-rust.yml @@ -0,0 +1,45 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Audit Rust + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/audit-rust.yml' + - '**/Cargo.lock' + - '**/Cargo.toml' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/audit-rust.yml' + - '**/Cargo.lock' + - '**/Cargo.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + audit-rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # https://github.com/tauri-apps/plugins-workspace/issues/774 + ignore: ${{ github.event_name != 'schedule' && 'RUSTSEC-2023-0071' || '' }} diff --git a/.github/workflows/check-change-files.yml b/.github/workflows/check-change-files.yml new file mode 100644 index 0000000..e6f4db1 --- /dev/null +++ b/.github/workflows/check-change-files.yml @@ -0,0 +1,47 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: check change files + +on: + pull_request: + paths: + - '.changes/*.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: check change files end with .md + run: | + for file in .changes/* + do + if [[ ! "$file" =~ \.(md|json)$ ]]; then + echo ".changes directory should only contain files that end with .md" + echo "found an invalid file in .changes directory:" + echo "$file" + exit 1 + fi + done + + - uses: dorny/paths-filter@v3 + id: filter + with: + list-files: shell + filters: | + changes: + - added|modified: '.changes/*.md' + + - name: check + run: node ./.scripts/ci/check-change-files.js ${{ steps.filter.outputs.changes_files }} + if: ${{ steps.filter.outputs.changes == 'true' }} diff --git a/.github/workflows/check-generated-files.yml b/.github/workflows/check-generated-files.yml new file mode 100644 index 0000000..11f40d1 --- /dev/null +++ b/.github/workflows/check-generated-files.yml @@ -0,0 +1,175 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: check generated files + +on: + pull_request: + paths: + - '.github/workflows/check-generated-files.yml' + - pnpm-lock.yaml + - '**/guest-js/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + autostart: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/autostart/guest-js/** + - plugins/autostart/src/api-iife.js + cli: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/cli/guest-js/** + - plugins/cli/src/api-iife.js + clipboard-manager: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/clipboard-manager/guest-js/** + - plugins/clipboard-manager/src/api-iife.js + dialog: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/dialog/guest-js/** + - plugins/dialog/src/api-iife.js + fs: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/fs/guest-js/** + - plugins/fs/src/api-iife.js + geolocation: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/geolocation/guest-js/** + - plugins/geolocation/src/api-iife.js + global-shortcut: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/global-shortcut/guest-js/** + - plugins/global-shortcut/src/api-iife.js + opener: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/opener/guest-js/** + - plugins/opener/src/api-iife.js + haptics: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/haptics/guest-js/** + - plugins/haptics/src/api-iife.js + http: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/http/guest-js/** + - plugins/http/src/api-iife.js + log: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/log/guest-js/** + - plugins/log/src/api-iife.js + notification: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/notification/guest-js/** + - plugins/notification/src/api-iife.js + os: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/os/guest-js/** + - plugins/os/src/api-iife.js + positioner: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/positioner/guest-js/** + - plugins/positioner/src/api-iife.js + process: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/process/guest-js/** + - plugins/process/src/api-iife.js + shell: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/shell/guest-js/** + - plugins/shell/src/api-iife.js + sql: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/sql/guest-js/** + - plugins/sql/src/api-iife.js + store: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/store/guest-js/** + - plugins/store/src/api-iife.js + stronghold: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/stronghold/guest-js/** + - plugins/stronghold/src/api-iife.js + updater: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/updater/guest-js/** + - plugins/updater/src/api-iife.js + upload: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/upload/guest-js/** + - plugins/upload/src/api-iife.js + websocket: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/websocket/guest-js/** + - plugins/websocket/src/api-iife.js + window-state: + - .github/workflows/check-generated-files.yml + - pnpm-lock.yaml + - plugins/window-state/guest-js/** + - plugins/window-state/src/api-iife.js + + test: + needs: changes + if: ${{ needs.changes.outputs.packages != '[]' && needs.changes.outputs.packages != '' }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.changes.outputs.packages) }} + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + + - name: build api + working-directory: plugins/${{ matrix.package }} + run: pnpm install && pnpm build + + - name: check diff + run: | + ./.scripts/ci/has-diff.sh diff --git a/.github/workflows/check-license-header.yml b/.github/workflows/check-license-header.yml new file mode 100644 index 0000000..0100d13 --- /dev/null +++ b/.github/workflows/check-license-header.yml @@ -0,0 +1,31 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: check license header + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@v2 + id: filter + with: + list-files: shell + filters: | + added: + - added: '**' + - name: check header license on new files + if: ${{ steps.filter.outputs.added == 'true' }} + run: node .scripts/ci/check-license-header.js ${{ steps.filter.outputs.added_files }} diff --git a/.github/workflows/covector-comment-on-fork.yml b/.github/workflows/covector-comment-on-fork.yml new file mode 100644 index 0000000..494a534 --- /dev/null +++ b/.github/workflows/covector-comment-on-fork.yml @@ -0,0 +1,30 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: covector comment +on: + workflow_run: + workflows: [covector status] # the `name` of the workflow run on `pull_request` running `status` with `comment: true` + types: + - completed + +# note all other permissions are set to none if not specified +# and these set the permissions for `secrets.GITHUB_TOKEN` +permissions: + # to read the action artifacts on `covector status` workflows + actions: read + # to write the comment + pull-requests: write + +jobs: + download: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.head_repository.full_name != github.repository || github.actor == 'dependabot[bot]') + steps: + - name: covector status + uses: jbolda/covector/packages/action@covector-v0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + command: 'status' diff --git a/.github/workflows/covector-status.yml b/.github/workflows/covector-status.yml new file mode 100644 index 0000000..0eced3a --- /dev/null +++ b/.github/workflows/covector-status.yml @@ -0,0 +1,25 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: covector status +on: [pull_request] + +permissions: + contents: read + +jobs: + covector: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # required for use of git history + - name: covector status + uses: jbolda/covector/packages/action@covector-v0 + id: covector + with: + command: 'status' + token: ${{ secrets.GITHUB_TOKEN }} + comment: true diff --git a/.github/workflows/covector-version-or-publish.yml b/.github/workflows/covector-version-or-publish.yml new file mode 100644 index 0000000..ca563b5 --- /dev/null +++ b/.github/workflows/covector-version-or-publish.yml @@ -0,0 +1,84 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: version or publish + +on: + push: + branches: + - v1 + - v2 + +permissions: + # required for oidc token + id-token: write + # required to create the GitHub Release + contents: write + # required for creating the Version Packages Release + pull-requests: write + +jobs: + version-or-publish: + runs-on: ubuntu-latest + timeout-minutes: 65 + outputs: + change: ${{ steps.covector.outputs.change }} + commandRan: ${{ steps.covector.outputs.commandRan }} + successfulPublish: ${{ steps.covector.outputs.successfulPublish }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # required for use of git history + + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + registry-url: 'https://registry.npmjs.org' + + - uses: pnpm/action-setup@v6 + with: + run_install: true + + - name: cargo login + run: cargo login ${{ secrets.ORG_CRATES_IO_TOKEN }} + + - name: git config + run: | + git config --global user.name "${{ github.event.pusher.name }}" + git config --global user.email "${{ github.event.pusher.email }}" + + - name: Setup target dir on /mnt + # This directory has a larger partition size + run: | + sudo mkdir /mnt/target + WORKSPACE_OWNER="$(stat -c '%U:%G' "${GITHUB_WORKSPACE}")" + sudo chown -R "${WORKSPACE_OWNER}" /mnt/target + + - name: covector version or publish (publish when no change files present) + uses: jbolda/covector/packages/action@covector-v0 + id: covector + env: + CARGO_TARGET_DIR: /mnt/target + with: + token: ${{ secrets.GITHUB_TOKEN }} + command: 'version-or-publish' + createRelease: true + recognizeContributors: true + + - name: Sync Cargo.lock + if: steps.covector.outputs.commandRan == 'version' + run: cargo tree --depth 0 + + - name: Create Pull Request With Versions Bumped + id: cpr + uses: peter-evans/create-pull-request@v8.1.1 + if: steps.covector.outputs.commandRan == 'version' + with: + title: 'Publish New Versions (${{ github.ref_name }})' + commit-message: 'publish new versions' + labels: 'version updates' + branch: 'ci/release-${{ github.ref_name }}' + body: ${{ steps.covector.outputs.change }} + sign-commits: true diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml new file mode 100644 index 0000000..bc23cdc --- /dev/null +++ b/.github/workflows/fmt.yml @@ -0,0 +1,55 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: check formatting + +on: + pull_request: + +permissions: + contents: read + +jobs: + rustfmt: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: install Rust stable and rustfmt + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: run cargo fmt + run: cargo fmt --all -- --check + + prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + - run: pnpm format:check + + taplo: + name: taplo (.toml files) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: install taplo-cli + uses: taiki-e/install-action@v2 + with: + tool: taplo-cli + + - run: taplo fmt --check --diff diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..27993d2 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,70 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: integration tests + +on: + workflow_dispatch: + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/integration-tests.yml' + - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/integration-tests.yml' + - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' + +permissions: + contents: read + +env: + CARGO_PROFILE_DEV_DEBUG: 0 # no debuginfo: faster builds and a much smaller target dir to cache + +jobs: + run-integration-tests: + runs-on: ${{ matrix.platform }} + + strategy: + fail-fast: false + matrix: + platform: [ubuntu-22.04, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: install stable + uses: dtolnay/rust-toolchain@stable + + - name: install Linux dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y webkit2gtk-4.0 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libfuse2 rpm + + - uses: Swatinem/rust-cache@v2 + with: + # Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its + # merge ref, where nothing else can restore it, and every write evicts an entry + # that other runs would have hit (10 GB repository limit, LRU eviction). + save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }} + + - name: install Tauri CLI + # `--debug --target-dir target`: a dev-profile build in the workspace target dir reuses + # the cached dependencies (shared with the test build below) instead of a from-scratch + # release build in a temporary dir. `--locked` keeps the dependency versions stable + # between runs, so the cached artifacts stay valid. + run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch dev --locked --debug --target-dir target + + - name: run integration tests + run: cargo test --test '*' -- --ignored diff --git a/.github/workflows/lint-javascript.yml b/.github/workflows/lint-javascript.yml new file mode 100644 index 0000000..c6dbce4 --- /dev/null +++ b/.github/workflows/lint-javascript.yml @@ -0,0 +1,59 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Lint JavaScript + +on: + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/lint-javascript.yml' + - 'plugins/*/guest-js/**' + - 'packages/**' + - '.eslintignore' + - '.eslintrc.json' + - '.prettierignore' + - '**/package.json' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/lint-javascript.yml' + - 'plugins/*/guest-js/**' + - 'packages/**' + - '.eslintignore' + - '.eslintrc.json' + - '.prettierignore' + - '**/package.json' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + # the type-checked lint rules (and packages/api-e2e) resolve the plugins' + # types from their dist-js build output + - name: build + run: pnpm build + - name: eslint + run: pnpm lint + - name: ts:check + run: pnpm ts:check diff --git a/.github/workflows/lint-rust.yml b/.github/workflows/lint-rust.yml new file mode 100644 index 0000000..ede7bf6 --- /dev/null +++ b/.github/workflows/lint-rust.yml @@ -0,0 +1,183 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Lint Rust + +on: + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/lint-rust.yml' + - 'plugins/*/src/**' + - '!plugins/*/src/api-iife.js' + - '**/Cargo.toml' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/lint-rust.yml' + - 'plugins/*/src/**' + - '!plugins/*/src/api-iife.js' + - '**/Cargo.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + packages: ${{ steps.filter.outputs.changes }} + saver: ${{ steps.saver.outputs.saver }} + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + tauri-plugin-autostart: + - .github/workflows/lint-rust.yml + - plugins/autostart/** + tauri-plugin-cli: + - .github/workflows/lint-rust.yml + - plugins/cli/** + tauri-plugin-clipboard-manager: + - .github/workflows/lint-rust.yml + - plugins/clipboard-manager/** + tauri-plugin-deep-link: + - .github/workflows/lint-rust.yml + - plugins/deep-link/** + tauri-plugin-dialog: + - .github/workflows/lint-rust.yml + - plugins/dialog/** + - plugins/fs/** + tauri-plugin-fs: + - .github/workflows/lint-rust.yml + - plugins/fs/** + tauri-plugin-geolocation: + - .github/workflows/lint-rust.yml + - plugins/geolocation/** + tauri-plugin-global-shortcut: + - .github/workflows/lint-rust.yml + - plugins/global-shortcut/** + tauri-plugin-opener: + - .github/workflows/lint-rust.yml + - plugins/opener/** + tauri-plugin-haptics: + - .github/workflows/lint-rust.yml + - plugins/haptics/** + tauri-plugin-http: + - .github/workflows/lint-rust.yml + - plugins/http/** + - plugins/fs/** + tauri-plugin-localhost: + - .github/workflows/lint-rust.yml + - plugins/localhost/** + tauri-plugin-log: + - .github/workflows/lint-rust.yml + - plugins/log/** + tauri-plugin-notification: + - .github/workflows/lint-rust.yml + - plugins/notification/** + tauri-plugin-os: + - .github/workflows/lint-rust.yml + - plugins/os/** + tauri-plugin-persisted-scope: + - .github/workflows/lint-rust.yml + - plugins/persisted-scope/** + - plugins/fs/** + tauri-plugin-positioner: + - .github/workflows/lint-rust.yml + - plugins/positioner/** + tauri-plugin-process: + - .github/workflows/lint-rust.yml + - plugins/process/** + tauri-plugin-shell: + - .github/workflows/lint-rust.yml + - plugins/shell/** + tauri-plugin-single-instance: + - .github/workflows/lint-rust.yml + - plugins/single-instance/** + tauri-plugin-sql: + - .github/workflows/lint-rust.yml + - plugins/sql/** + tauri-plugin-store: + - .github/workflows/lint-rust.yml + - plugins/store/** + tauri-plugin-stronghold: + - .github/workflows/lint-rust.yml + - plugins/stronghold/** + tauri-plugin-updater: + - .github/workflows/lint-rust.yml + - plugins/updater/** + tauri-plugin-upload: + - .github/workflows/lint-rust.yml + - plugins/upload/** + tauri-plugin-websocket: + - .github/workflows/lint-rust.yml + - plugins/websocket/** + tauri-plugin-window-state: + - .github/workflows/lint-rust.yml + - plugins/window-state/** + + # Every package job for a platform restores the same rust-cache entry, and exactly one + # of them saves it (see the `save-if` of the rust-cache step). Prefer the packages whose + # dependency tree covers most of the other plugins' dependencies, so the entry is as + # useful as possible for the packages that did not save it. + - name: pick the package that saves the cache + id: saver + env: + PACKAGES: ${{ steps.filter.outputs.changes }} + run: | + saver="$(jq -rn --argjson packages "${PACKAGES:-[]}" ' + ["tauri-plugin-upload", "tauri-plugin-sql", "tauri-plugin-updater", "tauri-plugin-single-instance"] + | map(select(. as $p | $packages | index($p) != null)) + | first // $packages[0] // empty + ')" + echo "saver=$saver" >> "$GITHUB_OUTPUT" + + clippy: + needs: changes + if: ${{ needs.changes.outputs.packages != '[]' && needs.changes.outputs.packages != '' }} + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.changes.outputs.packages) }} + + steps: + - uses: actions/checkout@v7 + + - name: install webkit2gtk + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev + + - name: Install clippy with stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + # One entry shared by every package job (the default key is per job, not per + # matrix entry), only written from `v2`/`v3` and by a single package job; see + # test-rust.yml for the reasoning. + save-if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && matrix.package == needs.changes.outputs.saver }} + + - name: clippy ${{ matrix.package }} + run: cargo clippy --package ${{ matrix.package }} --all-targets -- -D warnings + + - name: clippy ${{ matrix.package }} --all-features + if: matrix.package != 'tauri-plugin-dialog' + run: cargo clippy --package ${{ matrix.package }} --all-targets --all-features -- -D warnings diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..002f5bd --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,46 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Sync + +on: + workflow_dispatch: + push: + branches: + - v1 + - v2 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sync-to-mirrors: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Fetch git tags + run: git fetch origin 'refs/tags/*:refs/tags/*' + + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + + - uses: pnpm/action-setup@v6 + with: + run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + + - name: Build packages + run: pnpm build + + - name: Sync + run: .github/sync-to-mirrors.sh + env: + BUILD_BASE: ${{ github.workspace }}/plugins + API_TOKEN_GITHUB: ${{ secrets.ORG_TAURI_BOT_PAT }} diff --git a/.github/workflows/test-android.yml b/.github/workflows/test-android.yml new file mode 100644 index 0000000..45fdc08 --- /dev/null +++ b/.github/workflows/test-android.yml @@ -0,0 +1,78 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: test android + +on: + pull_request: + paths: + - '.github/workflows/test-android.yml' + - '**/android/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.platform }} + + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + # No caches on purpose: this workflow only runs on pull requests, so anything it saved + # would live on the merge ref where no other run can restore it, while still counting + # against the repository's 10 GB cache limit and evicting entries that `v2`/`v3` saved. + steps: + - uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@1.77.2 + + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + + - uses: pnpm/action-setup@v6 + with: + run_install: true + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Setup NDK + uses: nttld/setup-ndk@v1 + id: setup-ndk + with: + ndk-version: r25 + + # TODO check after https://github.com/nttld/setup-ndk/issues/518 is fixed + - name: Restore Android Symlinks + if: matrix.platform == 'ubuntu-latest' || matrix.platform == 'macos-latest' + env: + NDK_HOST: ${{ matrix.platform == 'ubuntu-latest' && 'linux-x86_64' || 'darwin-x86_64' }} + run: | + directory="${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/$NDK_HOST/bin" + find "$directory" -type l | while read link; do + current_target=$(readlink "$link") + new_target="$directory/$(basename "$current_target")" + ln -sf "$new_target" "$link" + echo "Changed $(basename "$link") from $current_target to $new_target" + done + + - name: build API + run: pnpm build + + - name: build APK + working-directory: ./examples/api + run: pnpm tauri android build + env: + NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} diff --git a/.github/workflows/test-api-e2e-mobile.yml b/.github/workflows/test-api-e2e-mobile.yml new file mode 100644 index 0000000..c73cb89 --- /dev/null +++ b/.github/workflows/test-api-e2e-mobile.yml @@ -0,0 +1,235 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: test plugins e2e (mobile) + +on: + workflow_dispatch: + push: + branches: + - v2 + paths: + - '.github/workflows/test-api-e2e-mobile.yml' + - 'packages/api-e2e/**' + - 'examples/api/**' + - 'plugins/*/guest-js/**' + - 'plugins/*/src/**' + - 'plugins/*/android/**' + - 'plugins/*/ios/**' + - 'plugins/*/permissions/**' + - 'plugins/*/build.rs' + pull_request: + branches: + - v2 + paths: + - '.github/workflows/test-api-e2e-mobile.yml' + - 'packages/api-e2e/**' + - 'examples/api/**' + - 'plugins/*/guest-js/**' + - 'plugins/*/src/**' + - 'plugins/*/android/**' + - 'plugins/*/ios/**' + - 'plugins/*/permissions/**' + - 'plugins/*/build.rs' + +env: + RUST_BACKTRACE: 1 + CARGO_PROFILE_DEV_DEBUG: 0 # keeps the target folder small for better cache efficiency + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + android: + runs-on: ubuntu-latest + env: + # The emulator runs the host architecture, so only that target is built. + E2E_ANDROID_TARGET: x86_64 + + steps: + - uses: actions/checkout@v7 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-linux-android + + - name: setup node + uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + # Same rule as the rust-cache step below: every run restores the Gradle cache and only + # `v2`/`v3` save it. setup-java's own `cache: gradle` has no such switch, so the restore + # and save steps are spelled out here (same paths and key inputs as setup-java uses). + - name: restore gradle cache + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Setup NDK + uses: nttld/setup-ndk@v1 + id: setup-ndk + with: + ndk-version: r25 + local-cache: true + + # TODO check after https://github.com/nttld/setup-ndk/issues/518 is fixed + - name: Restore Android Symlinks + run: | + directory="${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" + find "$directory" -type l | while read link; do + current_target=$(readlink "$link") + new_target="$directory/$(basename "$current_target")" + ln -sf "$new_target" "$link" + echo "Changed $(basename "$link") from $current_target to $new_target" + done + + # Hardware acceleration for the emulator. + - name: enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: Swatinem/rust-cache@v2 + with: + key: android + # Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its + # merge ref, where nothing else can restore it, and every write evicts an entry + # that other runs would have hit (10 GB repository limit, LRU eviction). + save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }} + + - name: install dependencies + run: pnpm i --frozen-lockfile + + - name: build plugins + run: pnpm build + + # Built before the emulator is up so it does not sit idle (and time out) + # during the Rust and Gradle builds; the suite then reuses the APK. + - name: build the APK + working-directory: ./examples/api + env: + NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + # a debug build without debug info, which would otherwise grow the APK + # past what the emulator can install (see androidBuildEnv in wdio.mobile.ts) + CARGO_PROFILE_DEV_DEBUG: '0' + run: pnpm tauri android build --debug --apk --target $E2E_ANDROID_TARGET + + - name: save gradle cache + if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && steps.gradle-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: run e2e tests + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 60 + with: + api-level: 35 + arch: x86_64 + # google_apis images ship an up-to-date Android System WebView. + target: google_apis + profile: pixel_6 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: E2E_SKIP_BUILD=1 E2E_SPEC_RETRIES=1 pnpm test:api-e2e:android + + - name: upload appium logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: android-appium-logs + path: packages/api-e2e/logs + if-no-files-found: ignore + + ios: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v7 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios-sim + + - name: setup node + uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + + - uses: Swatinem/rust-cache@v2 + with: + key: ios + # Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its + # merge ref, where nothing else can restore it, and every write evicts an entry + # that other runs would have hit (10 GB repository limit, LRU eviction). + save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }} + + - name: install dependencies + run: pnpm i --frozen-lockfile + + - name: build plugins + run: pnpm build + + # Simulator build (`aarch64-sim` on the Apple Silicon runners), unsigned. + # Built ahead of the suite for symmetry with the Android job. + - name: build the simulator app + working-directory: ./examples/api + run: pnpm tauri ios build --debug --target aarch64-sim --no-sign + + # wdio.ios.conf picks the iPhone simulator on the newest installed runtime + # (no device name is hardcoded) and Appium boots it; the XCUITest driver + # compiles WebDriverAgent on the first session. + - name: run e2e tests + timeout-minutes: 60 + env: + E2E_SKIP_BUILD: '1' + E2E_SPEC_RETRIES: '1' + run: pnpm test:api-e2e:ios + + - name: upload appium logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ios-appium-logs + path: packages/api-e2e/logs + if-no-files-found: ignore + + - name: collect app crash reports + if: failure() + run: .scripts/ci/collect-macos-crash-reports.sh "$RUNNER_TEMP/crash-reports" + + - name: upload app crash reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ios-crash-reports + path: ${{ runner.temp }}/crash-reports + if-no-files-found: ignore diff --git a/.github/workflows/test-api-e2e.yml b/.github/workflows/test-api-e2e.yml new file mode 100644 index 0000000..3c18ad5 --- /dev/null +++ b/.github/workflows/test-api-e2e.yml @@ -0,0 +1,176 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: test plugins e2e + +on: + workflow_dispatch: + push: + branches: + - v2 + paths: + - '.github/workflows/test-api-e2e.yml' + - 'packages/api-e2e/**' + - 'examples/api/**' + - 'plugins/*/guest-js/**' + - 'plugins/*/src/**' + - 'plugins/*/permissions/**' + - 'plugins/*/build.rs' + pull_request: + branches: + - v2 + paths: + - '.github/workflows/test-api-e2e.yml' + - 'packages/api-e2e/**' + - 'examples/api/**' + - 'plugins/*/guest-js/**' + - 'plugins/*/src/**' + - 'plugins/*/permissions/**' + - 'plugins/*/build.rs' + +env: + RUST_BACKTRACE: 1 + CARGO_PROFILE_DEV_DEBUG: 0 # keeps the target folder small for better cache efficiency + MSEDGEDRIVER_TOOL_REV: 8c4b34f51b45f5cf08013366d703de464ab871d1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.platform.os }} + + strategy: + fail-fast: false + matrix: + platform: + - { name: Linux, os: ubuntu-latest } + - { name: Windows, os: windows-latest } + - { name: macOS, os: macos-latest } + + steps: + - uses: actions/checkout@v7 + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: setup node + uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + - uses: pnpm/action-setup@v6 + with: + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow + + - name: install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + webkit2gtk-driver \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + dbus \ + xvfb \ + fluxbox \ + desktop-file-utils \ + xdg-utils + + # msedgedriver has to match the installed WebView2 runtime, so msedgedriver-tool + # downloads the right version at run time. The tool itself is built from a pinned git + # revision and kept in its own small cache entry (accessed by every Windows run, so it + # stays hot) instead of being rebuilt from source on every run. + - name: restore msedgedriver-tool (Windows) + if: runner.os == 'Windows' + id: msedgedriver-tool-cache + uses: actions/cache@v6 + with: + path: ${{ runner.tool_cache }}/msedgedriver-tool + key: msedgedriver-tool-${{ runner.os }}-${{ env.MSEDGEDRIVER_TOOL_REV }} + + - name: install msedgedriver-tool (Windows) + if: runner.os == 'Windows' && steps.msedgedriver-tool-cache.outputs.cache-hit != 'true' + run: cargo install --git https://github.com/chippers/msedgedriver-tool --rev ${{ env.MSEDGEDRIVER_TOOL_REV }} --locked --root ${{ runner.tool_cache }}/msedgedriver-tool + + - name: install msedgedriver (Windows) + if: runner.os == 'Windows' + run: | + & "${{ runner.tool_cache }}/msedgedriver-tool/bin/msedgedriver-tool.exe" + $PWD.Path >> $env:GITHUB_PATH + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.platform.os }} + # Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its + # merge ref, where nothing else can restore it, and every write evicts an entry + # that other runs would have hit (10 GB repository limit, LRU eviction). + save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }} + + - name: install dependencies + run: pnpm i --frozen-lockfile + + - name: build plugins + run: pnpm build + + - name: run e2e tests (Linux) + if: runner.os == 'Linux' + timeout-minutes: 45 + # A real window manager (fluxbox) is started so that window-state tests + # (size restore) behave, and a session D-Bus so the notification and + # clipboard plugins can reach their desktop services. + env: + E2E_SPEC_RETRIES: '1' + run: | + xvfb-run --auto-servernum -- dbus-run-session -- bash -c ' + fluxbox >/dev/null 2>&1 & + sleep 1 + pnpm test:api-e2e + ' + + - name: run e2e tests (Windows) + if: runner.os == 'Windows' + timeout-minutes: 45 + shell: pwsh + # msedgedriver hands the app the `--remote-debugging-port` it attaches to through + # `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, and WebView2 ignores that variable in an + # elevated process, which is what the runner gives us. gsudo runs the suite at medium + # integrity instead, and the workspace is opened up so the de-elevated process can use it. + # See https://github.com/tauri-apps/wry/issues/1782 and + # https://github.com/MicrosoftEdge/WebView2Feedback/issues/5645. + env: + E2E_SPEC_RETRIES: '1' + run: | + winget install gerardog.gsudo --accept-source-agreements --accept-package-agreements + icacls "$env:GITHUB_WORKSPACE" /grant "Everyone:(OI)(CI)F" + sudo --integrity Medium pnpm test:api-e2e + + - name: run e2e tests (macOS) + if: runner.os == 'macOS' && env.CN_API_KEY != '' + timeout-minutes: 45 + env: + CN_API_KEY: ${{ secrets.TAURI_E2E_CN_API_KEY }} + E2E_SPEC_RETRIES: '1' + # macOS has no native WebDriver for WKWebView, so the app is driven through the + # CrabNebula Webdriver (tauri-plugin-automation + test-runner-backend), which + # authenticates with CN_API_KEY. wdio.conf enables it automatically on darwin and + # builds the `.app` bundle it needs. + run: pnpm test:api-e2e + + - name: collect app crash reports (macOS) + if: runner.os == 'macOS' && failure() + run: .scripts/ci/collect-macos-crash-reports.sh "$RUNNER_TEMP/crash-reports" + + - name: upload app crash reports (macOS) + if: runner.os == 'macOS' && failure() + uses: actions/upload-artifact@v7 + with: + name: macos-crash-reports + path: ${{ runner.temp }}/crash-reports + if-no-files-found: ignore diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml new file mode 100644 index 0000000..7fda7d3 --- /dev/null +++ b/.github/workflows/test-rust.yml @@ -0,0 +1,311 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: Test Rust + +on: + push: + branches: + - v1 + - v2 + paths: + - '.github/workflows/test-rust.yml' + - 'plugins/*/src/**' + - '!plugins/*/src/api-iife.js' + - '**/Cargo.toml' + - '**/Cargo.lock' + pull_request: + branches: + - v1 + - v2 + paths: + - '.github/workflows/test-rust.yml' + - 'plugins/*/src/**' + - '!plugins/*/src/api-iife.js' + - '**/Cargo.toml' + - '**/Cargo.lock' + +env: + CARGO_PROFILE_DEV_DEBUG: 0 # no debuginfo: faster builds and a much smaller target dir to cache + CROSS_REV: 51f46f296253d8122c927c5bb933e3c4f27cc317 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + packages: ${{ steps.filter.outputs.changes }} + saver: ${{ steps.saver.outputs.saver }} + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@v2 + id: filter + with: + base: v2 + filters: | + tauri-plugin-autostart: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/autostart/** + tauri-plugin-cli: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/cli/** + tauri-plugin-clipboard-manager: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/clipboard-manager/** + tauri-plugin-deep-link: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/deep-link/** + tauri-plugin-dialog: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/dialog/** + - plugins/fs/** + tauri-plugin-fs: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/fs/** + tauri-plugin-geolocation: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/geolocation/** + tauri-plugin-global-shortcut: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/global-shortcut/** + tauri-plugin-opener: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/opener/** + tauri-plugin-haptics: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/haptics/** + tauri-plugin-http: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/http/** + - plugins/fs/** + tauri-plugin-localhost: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/localhost/** + tauri-plugin-log: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/log/** + tauri-plugin-notification: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/notification/** + tauri-plugin-os: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/os/** + tauri-plugin-persisted-scope: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/persisted-scope/** + - plugins/fs/** + tauri-plugin-positioner: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/positioner/** + tauri-plugin-process: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/process/** + tauri-plugin-shell: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/shell/** + tauri-plugin-single-instance: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/single-instance/** + tauri-plugin-sql: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/sql/** + tauri-plugin-store: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/store/** + tauri-plugin-stronghold: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/stronghold/** + tauri-plugin-updater: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/updater/** + tauri-plugin-upload: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/upload/** + tauri-plugin-websocket: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/websocket/** + tauri-plugin-window-state: + - .github/workflows/test-rust.yml + - Cargo.toml + - Cargo.lock + - plugins/window-state/** + + # Every package job for a platform restores the same rust-cache entry, and exactly one + # of them saves it (see the `save-if` of the rust-cache step). Prefer the packages whose + # dependency tree covers most of the other plugins' dependencies, so the entry is as + # useful as possible for the packages that did not save it. + - name: pick the package that saves the cache + id: saver + env: + PACKAGES: ${{ steps.filter.outputs.changes }} + run: | + saver="$(jq -rn --argjson packages "${PACKAGES:-[]}" ' + ["tauri-plugin-upload", "tauri-plugin-sql", "tauri-plugin-updater", "tauri-plugin-single-instance"] + | map(select(. as $p | $packages | index($p) != null)) + | first // $packages[0] // empty + ')" + echo "saver=$saver" >> "$GITHUB_OUTPUT" + + test: + needs: changes + if: ${{ needs.changes.outputs.packages != '[]' && needs.changes.outputs.packages != '' }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.changes.outputs.packages) }} + platform: + - { + target: x86_64-pc-windows-msvc, + os: windows-latest, + runner: 'cargo', + command: 'test' + } + - { + target: x86_64-unknown-linux-gnu, + os: ubuntu-22.04, + runner: 'cargo', + command: 'test' + } + - { + target: aarch64-apple-darwin, + os: macos-latest, + runner: 'cargo', + command: 'test' + } + - { + target: aarch64-apple-ios, + os: macos-latest, + runner: 'cargo', + command: 'build' + } + - { + target: aarch64-linux-android, + os: ubuntu-latest, + runner: 'cross', + command: 'build' + } + + runs-on: ${{ matrix.platform.os }} + + steps: + - uses: actions/checkout@v7 + + - name: install webkit2gtk + if: contains(matrix.platform.target, 'unknown-linux') + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev + + - uses: dtolnay/rust-toolchain@1.77.2 + with: + targets: ${{ matrix.platform.target }} + + - uses: Swatinem/rust-cache@v2 + with: + # One entry per platform, shared by every package job: the plugins share most of + # their dependency tree (tauri, wry, tao...), and an entry per package and platform + # (26 × 5 entries of 500-800 MB each) never fits the repository's 10 GB cache limit. + key: ${{ matrix.platform.target }} + # Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its + # merge ref, where nothing else can restore it, and every write evicts an entry + # that other runs would have hit (10 GB repository limit, LRU eviction). + # A single package job saves the shared entry, so the jobs don't race for the key. + save-if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && matrix.package == needs.changes.outputs.saver }} + + # `cross` is built from a pinned git revision (its latest release predates the Android + # targets we need). Keep the binary in its own small cache entry (accessed by every run, + # so it stays hot) instead of rebuilding it from source on every run. + - name: restore cross + if: ${{ matrix.platform.runner == 'cross' }} + id: cross-cache + uses: actions/cache@v6 + with: + path: ${{ runner.tool_cache }}/cross + key: cross-${{ runner.os }}-${{ env.CROSS_REV }} + + - name: install cross + if: ${{ matrix.platform.runner == 'cross' && steps.cross-cache.outputs.cache-hit != 'true' }} + run: cargo +stable install cross --git https://github.com/cross-rs/cross --rev ${{ env.CROSS_REV }} --locked --root ${{ runner.tool_cache }}/cross + + - name: add cross to PATH + if: ${{ matrix.platform.runner == 'cross' }} + run: echo "${{ runner.tool_cache }}/cross/bin" >> $GITHUB_PATH + + - name: test ${{ matrix.package }} + if: ${{ matrix.package != 'tauri-plugin-http' && matrix.package != 'tauri-plugin-dialog' }} + run: ${{ matrix.platform.runner }} ${{ matrix.platform.command }} --package ${{ matrix.package }} --target ${{ matrix.platform.target }} --all-targets --all-features + + - name: test ${{ matrix.package }} + if: ${{ matrix.package == 'tauri-plugin-http' || matrix.package == 'tauri-plugin-dialog' }} + run: ${{ matrix.platform.runner }} ${{ matrix.platform.command }} --package ${{ matrix.package }} --target ${{ matrix.platform.target }} --all-targets + + # `--all-targets` above does not include doc tests, so they are run separately. + # Only on the platforms we actually test on, since cargo skips doc tests when cross compiling. + - name: doc test ${{ matrix.package }} + if: ${{ matrix.platform.command == 'test' && matrix.package != 'tauri-plugin-http' && matrix.package != 'tauri-plugin-dialog' }} + run: ${{ matrix.platform.runner }} test --doc --package ${{ matrix.package }} --target ${{ matrix.platform.target }} --all-features + + - name: doc test ${{ matrix.package }} + if: ${{ matrix.platform.command == 'test' && (matrix.package == 'tauri-plugin-http' || matrix.package == 'tauri-plugin-dialog') }} + run: ${{ matrix.platform.runner }} test --doc --package ${{ matrix.package }} --target ${{ matrix.platform.target }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5110d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# dependency directories +node_modules/ +target/ + +# Optional npm and yarn cache directory +.npm/ +.yarn/ + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env + +# .vscode workspace settings file +.vscode/settings.json +.vscode/launch.json +.vscode/tasks.json + +# npm, yarn and bun lock files +package-lock.json +yarn.lock +bun.lockb +bun.lock + +# rust compiled folders +target/ + +# compiled plugins +dist-js/ + +# plugins .tauri directory +/plugins/*/.tauri + +# examples +examples/*/dist +plugins/*/examples/*/dist +examples/*/src-tauri/gen/schemas +plugins/*/examples/*/src-tauri/gen/schemas + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# runtime data +pids +*.pid +*.seed +*.pid.lock + +# miscellaneous +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea +debug.log +TODO.md +.aider.* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..30ab299 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +link-workspace-packages=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bc4fca6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,27 @@ +/.changes +/.vscode + +# dependcies and artifacts directories +node_modules/ +target/ +dist-js/ +dist/ + +# lock files +pnpm-lock.yaml + +# examples gen directory +examples/*/src-tauri/gen/ +plugins/*/examples/*/src-tauri/gen/ + +# autogenerated files +**/autogenerated/**/*.md +api-iife.js +init-iife.js +CHANGELOG.md +*schema.json + +# mobile build +**/ios/.build +**/.tauri +plugins/*/android/build diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6ca6fdd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "none", + "experimentalOperatorPosition": "start" +} diff --git a/.scripts/ci/check-change-files.js b/.scripts/ci/check-change-files.js new file mode 100644 index 0000000..c9ff7e9 --- /dev/null +++ b/.scripts/ci/check-change-files.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { readFileSync, readdirSync } from 'fs' +import { join } from 'path' + +/* const ignorePackages = [ + 'api-example', + 'api-example-js', + 'deep-link-example', + 'deep-link-example-js' +] */ + +const rsOnly = ['localhost', 'persisted-scope', 'single-instance'] + +function checkChangeFiles(changeFiles) { + let code = 0 + + for (const file of changeFiles) { + const content = readFileSync(file, 'utf8') + const [frontMatter] = /^---[\s\S.]*---\n/i.exec(content) + const packages = frontMatter + .split('\n') + .filter((l) => !(l === '---' || !l)) + .map((l) => l.replace(/('|")/g, '').split(':')) + + const rsPackages = Object.fromEntries( + packages + .filter((v) => !v[0].endsWith('-js')) + .map((v) => [v[0], v[1].trim()]) + ) + const jsPackages = Object.fromEntries( + packages + .filter((v) => v[0].endsWith('-js')) + .map((v) => [v[0].slice(0, -3), v[1].trim()]) + ) + + for (const pkg in rsPackages) { + if (rsOnly.includes(pkg)) continue + + if (!jsPackages[pkg]) { + console.error( + `Missing "${rsPackages[pkg]}" bump for JS package "${pkg}-js" in ${file}.` + ) + code = 1 + } else if (rsPackages[pkg] != jsPackages[pkg]) { + console.error( + `"${pkg}" and "${pkg}-js" have different version bumps in ${file}.` + ) + code = 1 + } + } + + for (const pkg in jsPackages) { + if (!rsPackages[pkg]) { + console.error( + `Missing "${jsPackages[pkg]}" bump for Rust package "${pkg}" in ${file}.` + ) + code = 1 + } else if (rsPackages[pkg] != jsPackages[pkg]) { + console.error( + `"${pkg}" and "${pkg}-js" have different version bumps in ${file}.` + ) + code = 1 + } + } + } + + process.exit(code) +} + +const [_bin, _script, ...files] = process.argv + +if (files.length > 0) { + checkChangeFiles( + files.filter((f) => f.toLowerCase() !== '.changes/readme.md') + ) +} else { + const changeFiles = readdirSync('.changes') + .filter((f) => f.endsWith('.md') && f.toLowerCase() !== 'readme.md') + .map((p) => join('.changes', p)) + checkChangeFiles(changeFiles) +} diff --git a/.scripts/ci/check-license-header.js b/.scripts/ci/check-license-header.js new file mode 100644 index 0000000..4341e5a --- /dev/null +++ b/.scripts/ci/check-license-header.js @@ -0,0 +1,130 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import fs from 'fs' +import path from 'path' +import readline from 'readline' + +const header = `Copyright 2019-2023 Tauri Programme within The Commons Conservancy +SPDX-License-Identifier: Apache-2.0 +SPDX-License-Identifier: MIT` +const ignoredLicenses = [ + '// Copyright 2021 Flavio Oliveira', + '// Copyright 2021 Jonas Kruckenberg', + '// Copyright 2018-2023 the Deno authors.' +] + +const extensions = ['.rs', '.js', '.ts', '.yml', '.swift', '.kt'] +const ignore = [ + 'target', + 'templates', + 'node_modules', + 'gen', + 'dist', + 'dist-js', + '.svelte-kit', + 'api-iife.js', + 'init-iife.js', + '.build', + 'notify_rust' +] + +async function checkFile(file) { + if ( + extensions.some((e) => file.endsWith(e)) + && !ignore.some((i) => file.includes(`${path.sep}${i}`)) + ) { + const fileStream = fs.createReadStream(file) + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }) + + let contents = `` + let i = 0 + for await (let line of rl) { + // ignore empty lines, allow shebang, swift-tools-version and bundler license + if ( + line.length === 0 + || line.startsWith('#!') + || line.startsWith('// swift-tools-version:') + || ignoredLicenses.includes(line) + ) { + continue + } + + // strip comment marker + if (line.startsWith('// ')) { + line = line.substring(3) + } else if (line.startsWith('# ')) { + line = line.substring(2) + } + + contents += line + if (++i === 3) { + break + } + contents += '\n' + } + if (contents !== header) { + return true + } + } + return false +} + +async function check(src) { + const missingHeader = [] + + for (const entry of fs.readdirSync(src, { + withFileTypes: true + })) { + const p = path.join(src, entry.name) + + if (entry.isSymbolicLink() || ignore.includes(entry.name)) { + continue + } + + if (entry.isDirectory()) { + const missing = await check(p) + missingHeader.push(...missing) + } else { + const isMissing = await checkFile(p) + if (isMissing) { + missingHeader.push(p) + } + } + } + + return missingHeader +} + +const [_bin, _script, ...files] = process.argv + +if (files.length > 0) { + async function run() { + const missing = [] + for (const f of files) { + const isMissing = await checkFile(f) + if (isMissing) { + missing.push(f) + } + } + if (missing.length > 0) { + console.log(missing.join('\n')) + process.exit(1) + } + } + + run() +} else { + check(path.resolve(new URL(import.meta.url).pathname, '../../..')).then( + (missing) => { + if (missing.length > 0) { + console.log(missing.join('\n')) + process.exit(1) + } + } + ) +} diff --git a/.scripts/ci/collect-macos-crash-reports.sh b/.scripts/ci/collect-macos-crash-reports.sh new file mode 100755 index 0000000..1706707 --- /dev/null +++ b/.scripts/ci/collect-macos-crash-reports.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +# Collects and prints macOS crash reports after a failed plugins e2e run. +# +# The CrabNebula Webdriver proxies every command to a server running *inside* the app +# process, so when the app dies the suite only ever reports `connection refused` — the +# actual cause is never in the job log. The app's stdout/stderr is inherited and no panic +# message is printed, which means it goes down on a signal. macOS records the reason in a +# crash report, and that is the only place it survives the run. +# +# Copies every report into $1 (default: $RUNNER_TEMP/crash-reports) for upload. + +set -euo pipefail + +dest="${1:-${RUNNER_TEMP:-/tmp}/crash-reports}" +mkdir -p "$dest" + +# ReportCrash writes asynchronously; give the last crash a moment to land. +sleep 5 + +found=0 +for f in "$HOME/Library/Logs/DiagnosticReports"/*.ips \ + "$HOME/Library/Logs/DiagnosticReports/Retired"/*.ips; do + [ -e "$f" ] || continue + found=$((found + 1)) + cp "$f" "$dest/" + echo "::group::$(basename "$f")" + # An .ips file is a one-line JSON header followed by the JSON report body. + head -n 1 "$f" + tail -n +2 "$f" | jq -r ' + "exception: \(.exception // {} | tojson)", + "termination: \(.termination // {} | tojson)", + "asi: \(.asi // {} | tojson)", + "faulting thread \(.faultingThread // 0):", + (. as $r + | $r.threads[$r.faultingThread // 0].frames[]? + | " \($r.usedImages[.imageIndex].name // "?") \(.symbol // "?") +\(.imageOffset)"), + "last ObjC exception:", + (. as $r + | $r.lastExceptionBacktrace[]? + | " \($r.usedImages[.imageIndex].name // "?") \(.symbol // "?") +\(.imageOffset)") + ' || cat "$f" + echo "::endgroup::" +done + +[ "$found" -gt 0 ] || echo "no crash reports under $HOME/Library/Logs/DiagnosticReports" diff --git a/.scripts/ci/has-diff.sh b/.scripts/ci/has-diff.sh new file mode 100755 index 0000000..1f18ac6 --- /dev/null +++ b/.scripts/ci/has-diff.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +if git diff --quiet --ignore-submodules HEAD +then + echo "working directory is clean" +else + echo "found diff" + git diff --name-status HEAD + exit 1 +fi diff --git a/.taurignore b/.taurignore new file mode 100644 index 0000000..28a49db --- /dev/null +++ b/.taurignore @@ -0,0 +1,2 @@ +plugins/*/permissions/autogenerated/ +plugins/*/android/.tauri/tauri-api/build/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..68acfc9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "EditorConfig.EditorConfig", + "esbenp.prettier-vscode", + "tamasfe.even-better-toml" + ] +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..23e38cb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[workspace] +members = [ + "plugins/*", + "plugins/*/tests/*", + "plugins/updater/tests/updater-migration/v2-app", + "plugins/*/examples/*/src-tauri", + "examples/*/src-tauri", +] +resolver = "2" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +tracing = "0.1" +log = "0.4.21" +tauri = { version = "2.10", default-features = false } +tauri-build = "2.5" +tauri-plugin = "2.5" +tauri-utils = "2.8" +serde_json = "1" +thiserror = "2" +url = "2" +schemars = "0.8" +dunce = "1" +specta = "^2.0.0-rc.16" +glob = "0.3" +zbus = "5.9" + +[workspace.package] +edition = "2021" +authors = ["Tauri Programme within The Commons Conservancy"] +license = "Apache-2.0 OR MIT" +rust-version = "1.77.2" +repository = "https://github.com/tauri-apps/plugins-workspace" + +# Stronghold encrypts its snapshots with scrypt: unoptimized, saving or loading one +# takes ~45s instead of ~1s, which stalls the (debug) example app and its e2e suite. +[profile.dev.package.scrypt] +opt-level = 3 +[profile.dev.package.salsa20] +opt-level = 3 +[profile.dev.package.pbkdf2] +opt-level = 3 +[profile.dev.package.sha2] +opt-level = 3 +[profile.dev.package.hmac] +opt-level = 3 +[profile.dev.package.iota-crypto] +opt-level = 3 +[profile.dev.package.rust-argon2] +opt-level = 3 + +# default to small, optimized release binaries +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +incremental = false +opt-level = "s" diff --git a/LICENSE.spdx b/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/LICENSE_APACHE-2.0 b/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/LICENSE_MIT b/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6f96c2 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Official Tauri Plugins + +This repo and all plugins require a Rust version of at least **1.77.2** + +## Plugins Found Here + +| | | Win | Mac | Lin | iOS | And | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | --- | --- | --- | --- | +| [autostart](plugins/autostart) | Automatically launch your app at system startup. | ✅ | ✅ | ✅ | ❌ | ❌ | +| [barcode-scanner](plugins/barcode-scanner) | Allows your mobile application to use the camera to scan QR codes, EAN-13 and other kinds of barcodes. | ? | ? | ? | ✅ | ✅ | +| [biometric](plugins/biometric) | Prompt the user for biometric authentication on Android and iOS. | ? | ? | ? | ✅ | ✅ | +| [cli](plugins/cli) | Parse arguments from your Command Line Interface | ✅ | ✅ | ✅ | ❌ | ❌ | +| [clipboard-manager](plugins/clipboard-manager) | Read and write to the system clipboard. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [deep-link](plugins/deep-link) | Set your Tauri application as the default handler for an URL. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [dialog](plugins/dialog) | Native system dialogs for opening and saving files along with message dialogs. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [fs](plugins/fs) | Access the file system. | ✅ | ✅ | ✅ | ? | ? | +| [geolocation](plugins/geolocation) | Get and track current device position. | ? | ? | ? | ✅ | ✅ | +| [global-shortcut](plugins/global-shortcut) | Register global shortcuts. | ✅ | ✅ | ✅ | ? | ? | +| [haptics](plugins/haptics) | Haptic feedback and vibrations. | ? | ? | ? | ✅ | ✅ | +| [http](plugins/http) | Access the HTTP client written in Rust. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [localhost](plugins/localhost) | Use a localhost server in production apps. | ✅ | ✅ | ✅ | ? | ? | +| [log](plugins/log) | Configurable logging. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [nfc](plugins/nfc) | Read and write NFC tags on Android and iOS. | ? | ? | ? | ✅ | ✅ | +| [notification](plugins/notification) | Send message notifications (brief auto-expiring OS window element) to your user. Can also be used with the Notification Web API. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [opener](plugins/opener) | Open files and URLs using their default application. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [os](plugins/os) | Read information about the operating system. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [persisted-scope](plugins/persisted-scope) | Persist runtime scope changes on the filesystem. | ✅ | ✅ | ✅ | ? | ? | +| [positioner](plugins/positioner) | Move windows to common locations. | ✅ | ✅ | ✅ | ❌ | ❌ | +| [process](plugins/process) | This plugin provides APIs to access the current process. To spawn child processes, see the [`shell`](https://github.com/tauri-apps/tauri-plugin-shell) plugin. | ✅ | ✅ | ✅ | ? | ? | +| [shell](plugins/shell) | Access the system shell. Allows you to spawn child processes and manage files and URLs using their default application. | ✅ | ✅ | ✅ | ? | ? | +| [single-instance](plugins/single-instance) | Ensure a single instance of your tauri app is running. | ✅ | ✅ | ✅ | ❌ | ❌ | +| [sql](plugins/sql) | Interface with SQL databases. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [store](plugins/store) | Persistent key value storage. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [stronghold](plugins/stronghold) | Encrypted, secure database. | ✅ | ✅ | ✅ | ? | ? | +| [updater](plugins/updater) | In-app updates for Tauri applications. | ✅ | ✅ | ✅ | ❌ | ❌ | +| [upload](plugins/upload) | Tauri plugin for file uploads through HTTP. | ✅ | ✅ | ✅ | ✅ | ✅ | +| [websocket](plugins/websocket) | Open a WebSocket connection using a Rust client in JS. | ✅ | ✅ | ✅ | ? | ? | +| [window-state](plugins/window-state) | Persist window sizes and positions. | ✅ | ✅ | ✅ | ❌ | ❌ | + +- ✅: (Partially) Supported +- ❌: Not supported +- `?` : Unknown/Untested or Planned + +## Contributing + +PRs accepted. Please make sure to read the [Contributing Guide](https://github.com/tauri-apps/tauri/blob/dev/.github/CONTRIBUTING.md) before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2500c68 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,39 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import eslint from '@eslint/js' +import { defineConfig } from 'eslint/config' +import eslintConfigPrettier from 'eslint-config-prettier' +import eslintPluginSecurity from 'eslint-plugin-security' +import tseslint from 'typescript-eslint' + +export default defineConfig( + { + ignores: [ + '**/target', + '**/node_modules', + '**/examples', + '**/dist', + '**/dist-js', + '**/build', + '**/api-iife.js', + '**/init-iife.js', + '**/init.js', + '**/rollup.config.js', + '**/bindings.ts', + '**/.test-server', + '.scripts', + 'eslint.config.js' + ] + }, + eslint.configs.recommended, + eslintConfigPrettier, + eslintPluginSecurity.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { project: true, tsconfigRootDir: import.meta.dirname } + } + } +) diff --git a/examples/api/.setup-cross.sh b/examples/api/.setup-cross.sh new file mode 100644 index 0000000..a9e4f86 --- /dev/null +++ b/examples/api/.setup-cross.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +export ICONS_VOLUME="$(realpath icons)" +export DIST_VOLUME="$(realpath dist)" +export ISOLATION_VOLUME="$(realpath isolation-dist)" diff --git a/examples/api/.taurignore b/examples/api/.taurignore new file mode 100644 index 0000000..848a0f2 --- /dev/null +++ b/examples/api/.taurignore @@ -0,0 +1,3 @@ +src-tauri/locales/ +src-tauri/Cross.toml +src-tauri/.gitignore diff --git a/examples/api/CHANGELOG.md b/examples/api/CHANGELOG.md new file mode 100644 index 0000000..77b1396 --- /dev/null +++ b/examples/api/CHANGELOG.md @@ -0,0 +1,836 @@ +# Changelog + +## [2.0.45] + +### Dependencies + +- Upgraded to `log-js@2.9.2` +- Upgraded to `updater-js@2.12.0` + +## [2.0.44] + +### Dependencies + +- Upgraded to `http-js@2.6.1` +- Upgraded to `store-js@2.4.5` + +## [2.0.43] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.4.6` +- Upgraded to `biometric-js@2.3.3` +- Upgraded to `clipboard-manager-js@2.3.3` +- Upgraded to `dialog-js@2.7.3` +- Upgraded to `fs-js@2.5.2` +- Upgraded to `nfc-js@2.3.6` +- Upgraded to `notification-js@2.4.0` +- Upgraded to `opener-js@2.5.5` +- Upgraded to `shell-js@2.3.6` +- Upgraded to `http-js@2.6.0` +- Upgraded to `log-js@2.9.1` +- Upgraded to `updater-js@2.11.0` + +## \[2.0.42] + +### Dependencies + +- Upgraded to `dialog-js@2.7.2` +- Upgraded to `store-js@2.4.4` + +## \[2.0.41] + +### Dependencies + +- Upgraded to `log-js@2.9.0` + +## \[2.0.40] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.4.5` +- Upgraded to `global-shortcut-js@2.3.2` + +## \[2.0.39] + +### Dependencies + +- Upgraded to `fs-js@2.5.1` +- Upgraded to `opener-js@2.5.4` +- Upgraded to `store-js@2.4.3` +- Upgraded to `dialog-js@2.7.1` +- Upgraded to `http-js@2.5.9` + +## \[2.0.38] + +### Dependencies + +- Upgraded to `fs-js@2.5.0` +- Upgraded to `http-js@2.5.8` +- Upgraded to `updater-js@2.10.1` +- Upgraded to `nfc-js@2.3.5` +- Upgraded to `dialog-js@2.7.0` + +## \[2.0.37] + +### Dependencies + +- Upgraded to `updater-js@2.10.0` +- Upgraded to `barcode-scanner-js@2.4.4` +- Upgraded to `http-js@2.5.7` +- Upgraded to `shell-js@2.3.5` + +## \[2.0.36] + +### Dependencies + +- Upgraded to `http-js@2.5.6` +- Upgraded to `dialog-js@2.6.0` + +## \[2.0.35] + +### Dependencies + +- Upgraded to `dialog-js@2.5.0` +- Upgraded to `log-js@2.8.0` +- Upgraded to `shell-js@2.3.4` +- Upgraded to `barcode-scanner-js@2.4.3` +- Upgraded to `fs-js@2.4.5` +- Upgraded to `http-js@2.5.5` +- Upgraded to `nfc-js@2.3.4` +- Upgraded to `opener-js@2.5.3` +- Upgraded to `store-js@2.4.2` + +## \[2.0.34] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.4.2` +- Upgraded to `biometric-js@2.3.2` +- Upgraded to `cli-js@2.4.1` +- Upgraded to `clipboard-manager-js@2.3.2` +- Upgraded to `dialog-js@2.4.2` +- Upgraded to `fs-js@2.4.4` +- Upgraded to `global-shortcut-js@2.3.1` +- Upgraded to `http-js@2.5.4` +- Upgraded to `log-js@2.7.1` +- Upgraded to `nfc-js@2.3.3` +- Upgraded to `notification-js@2.3.3` +- Upgraded to `opener-js@2.5.2` +- Upgraded to `os-js@2.3.2` +- Upgraded to `process-js@2.3.1` +- Upgraded to `shell-js@2.3.3` +- Upgraded to `store-js@2.4.1` + +## \[2.0.33] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.4.1` +- Upgraded to `biometric-js@2.3.1` +- Upgraded to `clipboard-manager-js@2.3.1` +- Upgraded to `dialog-js@2.4.1` +- Upgraded to `fs-js@2.4.3` +- Upgraded to `nfc-js@2.3.2` +- Upgraded to `notification-js@2.3.2` +- Upgraded to `opener-js@2.5.1` +- Upgraded to `shell-js@2.3.2` +- Upgraded to `http-js@2.5.3` + +## \[2.0.32] + +### Dependencies + +- Upgraded to `dialog-js@2.4.0` +- Upgraded to `log-js@2.7.0` + +## \[2.0.31] + +### Dependencies + +- Upgraded to `shell-js@2.3.1` + +## \[2.0.30] + +### Dependencies + +- Upgraded to `notification-js@2.3.1` + +## \[2.0.29] + +### Dependencies + +- Upgraded to `fs-js@2.4.2` +- Upgraded to `nfc-js@2.3.1` +- Upgraded to `opener-js@2.5.0` +- Upgraded to `os-js@2.3.1` +- Upgraded to `store-js@2.4.0` +- Upgraded to `dialog-js@2.3.3` +- Upgraded to `http-js@2.5.2` + +## \[2.0.28] + +### Dependencies + +- Upgraded to `dialog-js@2.3.2` + +## \[2.0.27] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.4.0` +- Upgraded to `fs-js@2.4.1` +- Upgraded to `dialog-js@2.3.1` +- Upgraded to `http-js@2.5.1` + +## \[2.0.26] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.3.0` +- Upgraded to `biometric-js@2.3.0` +- Upgraded to `cli-js@2.4.0` +- Upgraded to `clipboard-manager-js@2.3.0` +- Upgraded to `fs-js@2.4.0` +- Upgraded to `dialog-js@2.3.0` +- Upgraded to `global-shortcut-js@2.3.0` +- Upgraded to `opener-js@2.4.0` +- Upgraded to `http-js@2.5.0` +- Upgraded to `log-js@2.6.0` +- Upgraded to `nfc-js@2.3.0` +- Upgraded to `notification-js@2.3.0` +- Upgraded to `os-js@2.3.0` +- Upgraded to `process-js@2.3.0` +- Upgraded to `shell-js@2.3.0` +- Upgraded to `store-js@2.3.0` +- Upgraded to `updater-js@2.9.0` + +## \[2.0.25] + +### Dependencies + +- Upgraded to `cli-js@2.3.0` +- Upgraded to `log-js@2.5.1` +- Upgraded to `opener-js@2.3.1` + +## \[2.0.24] + +### Dependencies + +- Upgraded to `updater-js@2.8.1` + +## \[2.0.23] + +### Dependencies + +- Upgraded to `updater-js@2.8.0` +- Upgraded to `barcode-scanner-js@2.2.1` +- Upgraded to `biometric-js@2.2.2` +- Upgraded to `cli-js@2.2.1` +- Upgraded to `clipboard-manager-js@2.2.3` +- Upgraded to `nfc-js@2.2.1` +- Upgraded to `notification-js@2.2.3` +- Upgraded to `os-js@2.2.2` +- Upgraded to `process-js@2.2.2` +- Upgraded to `shell-js@2.2.2` +- Upgraded to `store-js@2.2.1` +- Upgraded to `log-js@2.5.0` +- Upgraded to `opener-js@2.3.0` + +## \[2.0.22] + +### Dependencies + +- Upgraded to `fs-js@2.3.0` +- Upgraded to `global-shortcut-js@2.2.1` +- Upgraded to `http-js@2.4.4` +- Upgraded to `opener-js@2.2.7` +- Upgraded to `dialog-js@2.2.2` + +## \[2.0.21] + +### Dependencies + +- Upgraded to `log-js@2.4.0` +- Upgraded to `biometric-js@2.2.1` +- Upgraded to `updater-js@2.7.1` + +## \[2.0.20] + +### Dependencies + +- Upgraded to `http-js@2.4.3` +- Upgraded to `shell-js@2.2.1` +- Upgraded to `fs-js@2.2.1` +- Upgraded to `process-js@2.2.1` +- Upgraded to `updater-js@2.7.0` +- Upgraded to `dialog-js@2.2.1` + +## \[2.0.19] + +### Dependencies + +- Upgraded to `http-js@2.4.2` +- Upgraded to `updater-js@2.6.1` + +## \[2.0.18] + +### Dependencies + +- Upgraded to `http-js@2.4.1` + +## \[2.0.17] + +### Dependencies + +- Upgraded to `log-js@2.3.1` + +## \[2.0.16] + +### Dependencies + +- Upgraded to `clipboard-manager-js@2.2.2` +- Upgraded to `notification-js@2.2.2` +- Upgraded to `os-js@2.2.1` +- Upgraded to `http-js@2.4.0` +- Upgraded to `log-js@2.3.0` +- Upgraded to `updater-js@2.6.0` + +## \[2.0.15] + +### Dependencies + +- Upgraded to `log-js@2.2.3` +- Upgraded to `opener-js@2.2.6` + +## \[2.0.14] + +### Dependencies + +- Upgraded to `log-js@2.2.2` +- Upgraded to `updater-js@2.5.1` + +## \[2.0.13] + +### Dependencies + +- Upgraded to `updater-js@2.5.0` + +## \[2.0.12] + +### Dependencies + +- Upgraded to `clipboard-manager-js@2.2.1` +- Upgraded to `http-js@2.3.0` +- Upgraded to `log-js@2.2.1` +- Upgraded to `updater-js@2.4.0` + +## \[2.0.11] + +### Dependencies + +- Upgraded to `opener-js@2.2.5` + +## \[2.0.10] + +### Dependencies + +- Upgraded to `notification-js@2.2.1` +- Upgraded to `opener-js@2.2.4` + +## \[2.0.9] + +### Dependencies + +- Upgraded to `opener-js@2.2.3` +- Upgraded to `updater-js@2.3.1` + +## \[2.0.8] + +### Dependencies + +- Upgraded to `opener-js@2.2.2` + +## \[2.0.7] + +### Dependencies + +- Upgraded to `updater-js@2.3.0` +- Upgraded to `opener-js@2.2.1` + +## \[2.0.6] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.1.0` +- Upgraded to `biometric-js@2.1.0` +- Upgraded to `cli-js@2.1.0` +- Upgraded to `clipboard-manager-js@2.1.0` +- Upgraded to `dialog-js@2.1.0` +- Upgraded to `fs-js@2.1.0` +- Upgraded to `global-shortcut-js@2.1.0` +- Upgraded to `http-js@2.1.0` +- Upgraded to `log-js@2.1.0` +- Upgraded to `nfc-js@2.1.0` +- Upgraded to `notification-js@2.1.0` +- Upgraded to `opener-js@2.1.0` +- Upgraded to `os-js@2.1.0` +- Upgraded to `process-js@2.1.0` +- Upgraded to `shell-js@2.1.0` +- Upgraded to `store-js@2.2.0` +- Upgraded to `updater-js@2.1.0` + +## \[2.0.5] + +### Dependencies + +- Upgraded to `fs-js@2.0.4` +- Upgraded to `dialog-js@2.0.2` +- Upgraded to `http-js@2.0.2` + +## \[2.0.4] + +### Dependencies + +- Upgraded to `log-js@2.0.2` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `clipboard-manager-js@2.0.1` +- Upgraded to `log-js@2.0.1` +- Upgraded to `fs-js@2.0.3` +- Upgraded to `opener-js@2.0.0` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `fs-js@2.0.2` + +## \[2.0.1] + +### Dependencies + +- Upgraded to `dialog-js@2.0.1` +- Upgraded to `fs-js@2.0.1` +- Upgraded to `http-js@2.0.1` +- Upgraded to `shell-js@2.0.1` +- Upgraded to `store-js@2.1.0` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0` +- Upgraded to `biometric-js@2.0.0` +- Upgraded to `cli-js@2.0.0` +- Upgraded to `clipboard-manager-js@2.0.0` +- Upgraded to `fs-js@2.0.0` +- Upgraded to `dialog-js@2.0.0` +- Upgraded to `global-shortcut-js@2.0.0` +- Upgraded to `http-js@2.0.0` +- Upgraded to `log-js@2.0.0` +- Upgraded to `nfc-js@2.0.0` +- Upgraded to `notification-js@2.0.0` +- Upgraded to `os-js@2.0.0` +- Upgraded to `process-js@2.0.0` +- Upgraded to `shell-js@2.0.0` +- Upgraded to `store-js@2.0.0` +- Upgraded to `updater-js@2.0.0` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `store-js@2.0.0-rc.2` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-rc.2` +- Upgraded to `clipboard-manager-js@2.0.0-rc.2` + +## \[2.0.0-rc.3] + +### Dependencies + +- Upgraded to `updater-js@2.0.0-rc.2` + +## \[2.0.0-rc.2] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-rc.1` +- Upgraded to `notification-js@2.0.0-rc.1` +- Upgraded to `dialog-js@2.0.0-rc.1` +- Upgraded to `biometric-js@2.0.0-rc.1` +- Upgraded to `cli-js@2.0.0-rc.1` +- Upgraded to `clipboard-manager-js@2.0.0-rc.1` +- Upgraded to `fs-js@2.0.0-rc.2` +- Upgraded to `global-shortcut-js@2.0.0-rc.1` +- Upgraded to `http-js@2.0.0-rc.2` +- Upgraded to `log-js@2.0.0-rc.1` +- Upgraded to `nfc-js@2.0.0-rc.1` +- Upgraded to `os-js@2.0.0-rc.1` +- Upgraded to `process-js@2.0.0-rc.1` +- Upgraded to `shell-js@2.0.0-rc.1` +- Upgraded to `store-js@2.0.0-rc.1` +- Upgraded to `updater-js@2.0.0-rc.1` + +## \[2.0.0-rc.1] + +### Dependencies + +- Upgraded to `http-js@2.0.0-rc.1` +- Upgraded to `fs-js@2.0.0-rc.1` + +## \[2.0.0-rc.0] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-rc.0` +- Upgraded to `biometric-js@2.0.0-rc.0` +- Upgraded to `cli-js@2.0.0-rc.0` +- Upgraded to `clipboard-manager-js@2.0.0-rc.0` +- Upgraded to `dialog-js@2.0.0-rc.0` +- Upgraded to `fs-js@2.0.0-rc.0` +- Upgraded to `global-shortcut-js@2.0.0-rc.0` +- Upgraded to `http-js@2.0.0-rc.0` +- Upgraded to `log-js@2.0.0-rc.0` +- Upgraded to `nfc-js@2.0.0-rc.0` +- Upgraded to `notification-js@2.0.0-rc.0` +- Upgraded to `os-js@2.0.0-rc.0` +- Upgraded to `process-js@2.0.0-rc.0` +- Upgraded to `shell-js@2.0.0-rc.0` +- Upgraded to `updater-js@2.0.0-rc.0` + +## \[2.0.0-beta.12] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-beta.8` +- Upgraded to `biometric-js@2.0.0-beta.8` +- Upgraded to `cli-js@2.0.0-beta.8` +- Upgraded to `clipboard-manager-js@2.1.0-beta.6` +- Upgraded to `dialog-js@2.0.0-beta.8` +- Upgraded to `fs-js@2.0.0-beta.8` +- Upgraded to `global-shortcut-js@2.0.0-beta.8` +- Upgraded to `http-js@2.0.0-beta.9` +- Upgraded to `log-js@2.0.0-beta.9` +- Upgraded to `nfc-js@2.0.0-beta.8` +- Upgraded to `notification-js@2.0.0-beta.8` +- Upgraded to `os-js@2.0.0-beta.8` +- Upgraded to `process-js@2.0.0-beta.8` +- Upgraded to `shell-js@2.0.0-beta.9` +- Upgraded to `updater-js@2.0.0-beta.8` + +## \[2.0.0-beta.11] + +### Dependencies + +- Upgraded to `global-shortcut-js@2.0.0-beta.7` +- Upgraded to `http-js@2.0.0-beta.8` +- Upgraded to `os-js@2.0.0-beta.7` +- Upgraded to `barcode-scanner-js@2.0.0-beta.7` +- Upgraded to `biometric-js@2.0.0-beta.7` +- Upgraded to `cli-js@2.0.0-beta.7` +- Upgraded to `clipboard-manager-js@2.1.0-beta.5` +- Upgraded to `dialog-js@2.0.0-beta.7` +- Upgraded to `fs-js@2.0.0-beta.7` +- Upgraded to `log-js@2.0.0-beta.8` +- Upgraded to `nfc-js@2.0.0-beta.7` +- Upgraded to `notification-js@2.0.0-beta.7` +- Upgraded to `process-js@2.0.0-beta.7` +- Upgraded to `shell-js@2.0.0-beta.8` +- Upgraded to `updater-js@2.0.0-beta.7` + +## \[2.0.0-beta.10] + +### Dependencies + +- Upgraded to `os-js@2.0.0-beta.6` +- Upgraded to `barcode-scanner-js@2.0.0-beta.6` +- Upgraded to `biometric-js@2.0.0-beta.6` +- Upgraded to `cli-js@2.0.0-beta.6` +- Upgraded to `clipboard-manager-js@2.1.0-beta.4` +- Upgraded to `dialog-js@2.0.0-beta.6` +- Upgraded to `fs-js@2.0.0-beta.6` +- Upgraded to `global-shortcut-js@2.0.0-beta.6` +- Upgraded to `http-js@2.0.0-beta.7` +- Upgraded to `log-js@2.0.0-beta.7` +- Upgraded to `nfc-js@2.0.0-beta.6` +- Upgraded to `notification-js@2.0.0-beta.6` +- Upgraded to `process-js@2.0.0-beta.6` +- Upgraded to `shell-js@2.0.0-beta.7` +- Upgraded to `updater-js@2.0.0-beta.6` + +## \[2.0.0-beta.9] + +### Dependencies + +- Upgraded to `http-js@2.0.0-beta.6` + +## \[2.0.0-beta.8] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-beta.5` +- Upgraded to `biometric-js@2.0.0-beta.5` +- Upgraded to `cli-js@2.0.0-beta.5` +- Upgraded to `clipboard-manager-js@2.1.0-beta.3` +- Upgraded to `dialog-js@2.0.0-beta.5` +- Upgraded to `fs-js@2.0.0-beta.5` +- Upgraded to `global-shortcut-js@2.0.0-beta.5` +- Upgraded to `http-js@2.0.0-beta.5` +- Upgraded to `log-js@2.0.0-beta.6` +- Upgraded to `nfc-js@2.0.0-beta.5` +- Upgraded to `notification-js@2.0.0-beta.5` +- Upgraded to `os-js@2.0.0-beta.5` +- Upgraded to `process-js@2.0.0-beta.5` +- Upgraded to `shell-js@2.0.0-beta.6` +- Upgraded to `updater-js@2.0.0-beta.5` + +## \[2.0.0-beta.7] + +### Dependencies + +- Upgraded to `http-js@2.0.0-beta.4` +- Upgraded to `barcode-scanner-js@2.0.0-beta.4` +- Upgraded to `biometric-js@2.0.0-beta.4` +- Upgraded to `cli-js@2.0.0-beta.4` +- Upgraded to `clipboard-manager-js@2.1.0-beta.2` +- Upgraded to `dialog-js@2.0.0-beta.4` +- Upgraded to `fs-js@2.0.0-beta.4` +- Upgraded to `global-shortcut-js@2.0.0-beta.4` +- Upgraded to `log-js@2.0.0-beta.5` +- Upgraded to `nfc-js@2.0.0-beta.4` +- Upgraded to `notification-js@2.0.0-beta.4` +- Upgraded to `os-js@2.0.0-beta.4` +- Upgraded to `process-js@2.0.0-beta.4` +- Upgraded to `shell-js@2.0.0-beta.5` +- Upgraded to `updater-js@2.0.0-beta.4` + +## \[2.0.0-beta.6] + +### Dependencies + +- Upgraded to `shell-js@2.0.0-beta.4` + +## \[2.0.0-beta.5] + +### Dependencies + +- Upgraded to `global-shortcut-js@2.0.0-beta.3` +- Upgraded to `barcode-scanner-js@2.0.0-beta.3` +- Upgraded to `biometric-js@2.0.0-beta.3` +- Upgraded to `cli-js@2.0.0-beta.3` +- Upgraded to `clipboard-manager-js@2.1.0-beta.1` +- Upgraded to `dialog-js@2.0.0-beta.3` +- Upgraded to `fs-js@2.0.0-beta.3` +- Upgraded to `http-js@2.0.0-beta.3` +- Upgraded to `log-js@2.0.0-beta.4` +- Upgraded to `nfc-js@2.0.0-beta.3` +- Upgraded to `notification-js@2.0.0-beta.3` +- Upgraded to `os-js@2.0.0-beta.3` +- Upgraded to `process-js@2.0.0-beta.3` +- Upgraded to `shell-js@2.0.0-beta.3` +- Upgraded to `updater-js@2.0.0-beta.3` + +## \[2.0.0-beta.4] + +### Dependencies + +- Upgraded to `log-js@2.0.0-beta.3` + +## \[2.0.0-beta.3] + +### Dependencies + +- Upgraded to `clipboard-manager-js@2.1.0-beta.0` + +## \[2.0.0-beta.2] + +### Dependencies + +- Upgraded to `clipboard-manager-js@2.0.0-beta.2` +- Upgraded to `dialog-js@2.0.0-beta.2` +- Upgraded to `fs-js@2.0.0-beta.2` +- Upgraded to `http-js@2.0.0-beta.2` +- Upgraded to `shell-js@2.0.0-beta.2` +- Upgraded to `barcode-scanner-js@2.0.0-beta.2` +- Upgraded to `biometric-js@2.0.0-beta.2` +- Upgraded to `cli-js@2.0.0-beta.2` +- Upgraded to `global-shortcut-js@2.0.0-beta.2` +- Upgraded to `log-js@2.0.0-beta.2` +- Upgraded to `nfc-js@2.0.0-beta.2` +- Upgraded to `notification-js@2.0.0-beta.2` +- Upgraded to `os-js@2.0.0-beta.2` +- Upgraded to `process-js@2.0.0-beta.2` +- Upgraded to `updater-js@2.0.0-beta.2` + +## \[2.0.0-beta.1] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-beta.1` +- Upgraded to `biometric-js@2.0.0-beta.1` +- Upgraded to `cli-js@2.0.0-beta.1` +- Upgraded to `clipboard-manager-js@2.0.0-beta.1` +- Upgraded to `dialog-js@2.0.0-beta.1` +- Upgraded to `fs-js@2.0.0-beta.1` +- Upgraded to `global-shortcut-js@2.0.0-beta.1` +- Upgraded to `http-js@2.0.0-beta.1` +- Upgraded to `log-js@2.0.0-beta.1` +- Upgraded to `nfc-js@2.0.0-beta.1` +- Upgraded to `notification-js@2.0.0-beta.1` +- Upgraded to `os-js@2.0.0-beta.1` +- Upgraded to `process-js@2.0.0-beta.1` +- Upgraded to `shell-js@2.0.0-beta.1` +- Upgraded to `updater-js@2.0.0-beta.1` + +## \[2.0.0-beta.0] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-beta.0` +- Upgraded to `biometric-js@2.0.0-beta.0` +- Upgraded to `cli-js@2.0.0-beta.0` +- Upgraded to `clipboard-manager-js@2.0.0-beta.0` +- Upgraded to `dialog-js@2.0.0-beta.0` +- Upgraded to `fs-js@2.0.0-beta.0` +- Upgraded to `global-shortcut-js@2.0.0-beta.0` +- Upgraded to `http-js@2.0.0-beta.0` +- Upgraded to `log-js@2.0.0-beta.0` +- Upgraded to `nfc-js@2.0.0-beta.0` +- Upgraded to `notification-js@2.0.0-beta.0` +- Upgraded to `os-js@2.0.0-beta.0` +- Upgraded to `process-js@2.0.0-beta.0` +- Upgraded to `shell-js@2.0.0-beta.0` +- Upgraded to `updater-js@2.0.0-beta.0` + +## \[2.0.0-alpha.9] + +### Dependencies + +- Upgraded to `fs-js@2.0.0-alpha.6` + +## \[2.0.0-alpha.8] + +### Dependencies + +- Upgraded to `http-js@2.0.0-alpha.6` + +## \[2.0.0-alpha.7] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-alpha.4` +- Upgraded to `cli-js@2.0.0-alpha.5` +- Upgraded to `clipboard-manager-js@2.0.0-alpha.5` +- Upgraded to `dialog-js@2.0.0-alpha.5` +- Upgraded to `fs-js@2.0.0-alpha.5` +- Upgraded to `global-shortcut-js@2.0.0-alpha.5` +- Upgraded to `http-js@2.0.0-alpha.5` +- Upgraded to `log-js@2.0.0-alpha.5` +- Upgraded to `notification-js@2.0.0-alpha.5` +- Upgraded to `os-js@2.0.0-alpha.6` +- Upgraded to `process-js@2.0.0-alpha.5` +- Upgraded to `shell-js@2.0.0-alpha.5` +- Upgraded to `updater-js@2.0.0-alpha.5` +- Upgraded to `biometric-js@2.0.0-alpha.0` +- Upgraded to `nfc-js@2.0.0-alpha.0` + +## \[2.0.0-alpha.6] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-alpha.3` +- Upgraded to `cli-js@2.0.0-alpha.4` +- Upgraded to `clipboard-manager-js@2.0.0-alpha.4` +- Upgraded to `dialog-js@2.0.0-alpha.4` +- Upgraded to `fs-js@2.0.0-alpha.4` +- Upgraded to `global-shortcut-js@2.0.0-alpha.4` +- Upgraded to `http-js@2.0.0-alpha.4` +- Upgraded to `log-js@2.0.0-alpha.4` +- Upgraded to `notification-js@2.0.0-alpha.4` +- Upgraded to `os-js@2.0.0-alpha.5` +- Upgraded to `process-js@2.0.0-alpha.4` +- Upgraded to `shell-js@2.0.0-alpha.4` +- Upgraded to `updater-js@2.0.0-alpha.4` + +## \[2.0.0-alpha.5] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-alpha.2` +- Upgraded to `cli-js@2.0.0-alpha.3` +- Upgraded to `clipboard-manager-js@2.0.0-alpha.3` +- Upgraded to `dialog-js@2.0.0-alpha.3` +- Upgraded to `fs-js@2.0.0-alpha.3` +- Upgraded to `global-shortcut-js@2.0.0-alpha.3` +- Upgraded to `http-js@2.0.0-alpha.3` +- Upgraded to `log-js@2.0.0-alpha.3` +- Upgraded to `notification-js@2.0.0-alpha.3` +- Upgraded to `os-js@2.0.0-alpha.4` +- Upgraded to `process-js@2.0.0-alpha.3` +- Upgraded to `shell-js@2.0.0-alpha.3` +- Upgraded to `updater-js@2.0.0-alpha.3` + +## \[2.0.0-alpha.4] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-alpha.1` +- Upgraded to `cli-js@2.0.0-alpha.2` +- Upgraded to `clipboard-manager-js@2.0.0-alpha.2` +- Upgraded to `dialog-js@2.0.0-alpha.2` +- Upgraded to `fs-js@2.0.0-alpha.2` +- Upgraded to `global-shortcut-js@2.0.0-alpha.2` +- Upgraded to `http-js@2.0.0-alpha.2` +- Upgraded to `log-js@2.0.0-alpha.2` +- Upgraded to `notification-js@2.0.0-alpha.2` +- Upgraded to `os-js@2.0.0-alpha.3` +- Upgraded to `process-js@2.0.0-alpha.2` +- Upgraded to `shell-js@2.0.0-alpha.2` +- Upgraded to `updater-js@2.0.0-alpha.2` + +## \[2.0.0-alpha.3] + +### Dependencies + +- Upgraded to `barcode-scanner-js@2.0.0-alpha.0` + +## \[2.0.0-alpha.2] + +### Dependencies + +- Upgraded to `os-js@2.0.0-alpha.2` + +## \[2.0.0-alpha.1] + +### Dependencies + +- Upgraded to `window-js@2.0.0-alpha.1` +- Upgraded to `fs-js@2.0.0-alpha.1` +- Upgraded to `http-js@2.0.0-alpha.1` +- Upgraded to `os-js@2.0.0-alpha.1` +- Upgraded to `app-js@2.0.0-alpha.1` +- Upgraded to `cli-js@2.0.0-alpha.1` +- Upgraded to `dialog-js@2.0.0-alpha.1` +- Upgraded to `global-shortcut-js@2.0.0-alpha.1` +- Upgraded to `log-js@2.0.0-alpha.1` +- Upgraded to `notification-js@2.0.0-alpha.1` +- Upgraded to `process-js@2.0.0-alpha.1` +- Upgraded to `shell-js@2.0.0-alpha.1` +- Upgraded to `updater-js@2.0.0-alpha.1` + +## \[2.0.0-alpha.0] + +### Dependencies + +- Plugins v2 alpha. diff --git a/examples/api/README.md b/examples/api/README.md new file mode 100644 index 0000000..60de2fc --- /dev/null +++ b/examples/api/README.md @@ -0,0 +1,28 @@ +# API example + +This example demonstrates Tauri's API capabilities using the plugins from this repository. It's used as the main validation app, serving as the testbed of our development process. +In the future, this app will be used on Tauri's integration tests. + +![App screenshot](./screenshot.png?raw=true) + +## Running the example + +- Install dependencies and build packages (Run inside of the repository root) + +```bash +$ pnpm install +$ pnpm build +``` + +- Run the app in development mode (Run inside of this folder `examples/api/`) + +```bash +$ pnpm tauri dev +``` + +- Build an run the release app (Run inside of this folder `examples/api/`) + +```bash +$ pnpm tauri build +$ ./src-tauri/target/release/app +``` diff --git a/examples/api/index.html b/examples/api/index.html new file mode 100644 index 0000000..acd46f1 --- /dev/null +++ b/examples/api/index.html @@ -0,0 +1,17 @@ + + + + + + + API Example App + + + +
+ + + diff --git a/examples/api/isolation-dist/index.html b/examples/api/isolation-dist/index.html new file mode 100644 index 0000000..a97caa8 --- /dev/null +++ b/examples/api/isolation-dist/index.html @@ -0,0 +1,10 @@ + + + + + Isolation Secure Script + + + + + diff --git a/examples/api/isolation-dist/index.js b/examples/api/isolation-dist/index.js new file mode 100644 index 0000000..7e2df30 --- /dev/null +++ b/examples/api/isolation-dist/index.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +window.__TAURI_ISOLATION_HOOK__ = (payload) => { + return payload +} diff --git a/examples/api/package.json b/examples/api/package.json new file mode 100644 index 0000000..c788209 --- /dev/null +++ b/examples/api/package.json @@ -0,0 +1,51 @@ +{ + "name": "api", + "private": true, + "version": "2.0.45", + "type": "module", + "scripts": { + "dev": "vite --clearScreen false", + "build": "vite build", + "serve": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-autostart": "workspace:*", + "@tauri-apps/plugin-barcode-scanner": "workspace:*", + "@tauri-apps/plugin-biometric": "workspace:*", + "@tauri-apps/plugin-cli": "workspace:*", + "@tauri-apps/plugin-clipboard-manager": "workspace:*", + "@tauri-apps/plugin-deep-link": "workspace:*", + "@tauri-apps/plugin-dialog": "workspace:*", + "@tauri-apps/plugin-fs": "workspace:*", + "@tauri-apps/plugin-geolocation": "workspace:*", + "@tauri-apps/plugin-global-shortcut": "workspace:*", + "@tauri-apps/plugin-haptics": "workspace:*", + "@tauri-apps/plugin-http": "workspace:*", + "@tauri-apps/plugin-nfc": "workspace:*", + "@tauri-apps/plugin-notification": "workspace:*", + "@tauri-apps/plugin-opener": "workspace:*", + "@tauri-apps/plugin-os": "workspace:*", + "@tauri-apps/plugin-positioner": "workspace:*", + "@tauri-apps/plugin-process": "workspace:*", + "@tauri-apps/plugin-shell": "workspace:*", + "@tauri-apps/plugin-sql": "workspace:*", + "@tauri-apps/plugin-store": "workspace:*", + "@tauri-apps/plugin-stronghold": "workspace:*", + "@tauri-apps/plugin-updater": "workspace:*", + "@tauri-apps/plugin-upload": "workspace:*", + "@tauri-apps/plugin-websocket": "workspace:*", + "@zerodevx/svelte-json-view": "2.0.0" + }, + "devDependencies": { + "@iconify-json/codicon": "^1.2.49", + "@iconify-json/ph": "^1.2.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tauri-apps/cli": "2.11.4", + "@unocss/extractor-svelte": "^66.6.7", + "svelte": "^5.55.7", + "unocss": "^66.6.7", + "vite": "^8.0.16" + } +} diff --git a/examples/api/public/tauri_logo.png b/examples/api/public/tauri_logo.png new file mode 100644 index 0000000..2c53b8c Binary files /dev/null and b/examples/api/public/tauri_logo.png differ diff --git a/examples/api/src-tauri/.gitignore b/examples/api/src-tauri/.gitignore new file mode 100644 index 0000000..99cb2b7 --- /dev/null +++ b/examples/api/src-tauri/.gitignore @@ -0,0 +1,10 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# cargo-mobile +.cargo/ + +gen/schemas/*.json +!gen/schemas/desktop-schema.json +!gen/schemas/mobile-schema.json diff --git a/examples/api/src-tauri/.taurignore b/examples/api/src-tauri/.taurignore new file mode 100644 index 0000000..cbff529 --- /dev/null +++ b/examples/api/src-tauri/.taurignore @@ -0,0 +1 @@ +tauri-plugin-sample/ \ No newline at end of file diff --git a/examples/api/src-tauri/CHANGELOG.md b/examples/api/src-tauri/CHANGELOG.md new file mode 100644 index 0000000..cb78a36 --- /dev/null +++ b/examples/api/src-tauri/CHANGELOG.md @@ -0,0 +1,1037 @@ +# Changelog + +## [2.0.49] + +### Dependencies + +- Upgraded to `http@2.7.0` +- Upgraded to `log@2.9.2` +- Upgraded to `updater@2.12.0` + +## [2.0.48] + +### Dependencies + +- Upgraded to `http@2.6.1` +- Upgraded to `store@2.4.5` + +## [2.0.47] + +### Dependencies + +- Upgraded to `barcode-scanner@2.4.6` +- Upgraded to `biometric@2.3.3` +- Upgraded to `clipboard-manager@2.3.3` +- Upgraded to `dialog@2.7.3` +- Upgraded to `fs@2.5.2` +- Upgraded to `geolocation@2.3.3` +- Upgraded to `haptics@2.3.3` +- Upgraded to `nfc@2.3.6` +- Upgraded to `notification@2.4.0` +- Upgraded to `opener@2.5.5` +- Upgraded to `shell@2.3.6` +- Upgraded to `http@2.6.0` +- Upgraded to `log@2.9.1` +- Upgraded to `updater@2.11.0` + +## \[2.0.46] + +### Dependencies + +- Upgraded to `dialog@2.7.2` +- Upgraded to `store@2.4.4` + +## \[2.0.45] + +### Dependencies + +- Upgraded to `log@2.9.0` + +## \[2.0.44] + +### Dependencies + +- Upgraded to `barcode-scanner@2.4.5` +- Upgraded to `global-shortcut@2.3.2` + +## \[2.0.43] + +### Dependencies + +- Upgraded to `fs@2.5.1` +- Upgraded to `opener@2.5.4` +- Upgraded to `store@2.4.3` +- Upgraded to `dialog@2.7.1` +- Upgraded to `http@2.5.9` + +## \[2.0.42] + +### Dependencies + +- Upgraded to `fs@2.5.0` +- Upgraded to `http@2.5.8` +- Upgraded to `updater@2.10.1` +- Upgraded to `nfc@2.3.5` +- Upgraded to `dialog@2.7.0` + +## \[2.0.41] + +### Dependencies + +- Upgraded to `updater@2.10.0` +- Upgraded to `barcode-scanner@2.4.4` +- Upgraded to `http@2.5.7` +- Upgraded to `shell@2.3.5` + +## \[2.0.40] + +### Dependencies + +- Upgraded to `http@2.5.6` +- Upgraded to `dialog@2.6.0` + +## \[2.0.39] + +### Dependencies + +- Upgraded to `dialog@2.5.0` +- Upgraded to `log@2.8.0` +- Upgraded to `shell@2.3.4` +- Upgraded to `barcode-scanner@2.4.3` +- Upgraded to `fs@2.4.5` +- Upgraded to `http@2.5.5` +- Upgraded to `nfc@2.3.4` +- Upgraded to `opener@2.5.3` +- Upgraded to `store@2.4.2` + +## \[2.0.38] + +### Dependencies + +- Upgraded to `barcode-scanner@2.4.2` +- Upgraded to `biometric@2.3.2` +- Upgraded to `cli@2.4.1` +- Upgraded to `clipboard-manager@2.3.2` +- Upgraded to `dialog@2.4.2` +- Upgraded to `fs@2.4.4` +- Upgraded to `geolocation@2.3.2` +- Upgraded to `global-shortcut@2.3.1` +- Upgraded to `haptics@2.3.2` +- Upgraded to `http@2.5.4` +- Upgraded to `log@2.7.1` +- Upgraded to `nfc@2.3.3` +- Upgraded to `notification@2.3.3` +- Upgraded to `opener@2.5.2` +- Upgraded to `os@2.3.2` +- Upgraded to `process@2.3.1` +- Upgraded to `shell@2.3.3` +- Upgraded to `store@2.4.1` + +## \[2.0.37] + +### Dependencies + +- Upgraded to `barcode-scanner@2.4.1` +- Upgraded to `biometric@2.3.1` +- Upgraded to `clipboard-manager@2.3.1` +- Upgraded to `dialog@2.4.1` +- Upgraded to `fs@2.4.3` +- Upgraded to `geolocation@2.3.1` +- Upgraded to `haptics@2.3.1` +- Upgraded to `nfc@2.3.2` +- Upgraded to `notification@2.3.2` +- Upgraded to `opener@2.5.1` +- Upgraded to `shell@2.3.2` +- Upgraded to `http@2.5.3` + +## \[2.0.36] + +### Dependencies + +- Upgraded to `dialog@2.4.0` +- Upgraded to `log@2.7.0` + +## \[2.0.35] + +### Dependencies + +- Upgraded to `shell@2.3.1` + +## \[2.0.34] + +### Dependencies + +- Upgraded to `notification@2.3.1` + +## \[2.0.33] + +### Dependencies + +- Upgraded to `fs@2.4.2` +- Upgraded to `nfc@2.3.1` +- Upgraded to `opener@2.5.0` +- Upgraded to `os@2.3.1` +- Upgraded to `store@2.4.0` +- Upgraded to `dialog@2.3.3` +- Upgraded to `http@2.5.2` + +## \[2.0.32] + +### Dependencies + +- Upgraded to `dialog@2.3.2` + +## \[2.0.31] + +### Dependencies + +- Upgraded to `barcode-scanner@2.4.0` +- Upgraded to `fs@2.4.1` +- Upgraded to `dialog@2.3.1` +- Upgraded to `http@2.5.1` + +## \[2.0.30] + +### Dependencies + +- Upgraded to `barcode-scanner@2.3.0` +- Upgraded to `biometric@2.3.0` +- Upgraded to `cli@2.4.0` +- Upgraded to `clipboard-manager@2.3.0` +- Upgraded to `fs@2.4.0` +- Upgraded to `dialog@2.3.0` +- Upgraded to `geolocation@2.3.0` +- Upgraded to `global-shortcut@2.3.0` +- Upgraded to `opener@2.4.0` +- Upgraded to `haptics@2.3.0` +- Upgraded to `http@2.5.0` +- Upgraded to `log@2.6.0` +- Upgraded to `nfc@2.3.0` +- Upgraded to `notification@2.3.0` +- Upgraded to `os@2.3.0` +- Upgraded to `process@2.3.0` +- Upgraded to `shell@2.3.0` +- Upgraded to `store@2.3.0` +- Upgraded to `updater@2.9.0` + +## \[2.0.29] + +### Dependencies + +- Upgraded to `cli@2.3.0` +- Upgraded to `log@2.5.1` +- Upgraded to `opener@2.3.1` + +## \[2.0.28] + +### Dependencies + +- Upgraded to `updater@2.8.1` + +## \[2.0.27] + +### Dependencies + +- Upgraded to `updater@2.8.0` +- Upgraded to `barcode-scanner@2.2.1` +- Upgraded to `biometric@2.2.2` +- Upgraded to `cli@2.2.1` +- Upgraded to `clipboard-manager@2.2.3` +- Upgraded to `geolocation@2.2.5` +- Upgraded to `haptics@2.2.5` +- Upgraded to `nfc@2.2.1` +- Upgraded to `notification@2.2.3` +- Upgraded to `os@2.2.2` +- Upgraded to `process@2.2.2` +- Upgraded to `shell@2.2.2` +- Upgraded to `store@2.2.1` +- Upgraded to `log@2.5.0` +- Upgraded to `opener@2.3.0` + +## \[2.0.26] + +### Dependencies + +- Upgraded to `fs@2.3.0` +- Upgraded to `global-shortcut@2.2.1` +- Upgraded to `http@2.4.4` +- Upgraded to `opener@2.2.7` +- Upgraded to `dialog@2.2.2` + +## \[2.0.25] + +### Dependencies + +- Upgraded to `log@2.4.0` +- Upgraded to `biometric@2.2.1` +- Upgraded to `updater@2.7.1` + +## \[2.0.24] + +### Dependencies + +- Upgraded to `http@2.4.3` +- Upgraded to `shell@2.2.1` +- Upgraded to `fs@2.2.1` +- Upgraded to `process@2.2.1` +- Upgraded to `updater@2.7.0` +- Upgraded to `dialog@2.2.1` + +## \[2.0.23] + +### Dependencies + +- Upgraded to `http@2.4.2` +- Upgraded to `updater@2.6.1` + +## \[2.0.22] + +### Dependencies + +- Upgraded to `http@2.4.1` + +## \[2.0.21] + +### Dependencies + +- Upgraded to `log@2.3.1` + +## \[2.0.20] + +### Dependencies + +- Upgraded to `clipboard-manager@2.2.2` +- Upgraded to `geolocation@2.2.4` +- Upgraded to `haptics@2.2.4` +- Upgraded to `notification@2.2.2` +- Upgraded to `os@2.2.1` +- Upgraded to `http@2.4.0` +- Upgraded to `log@2.3.0` +- Upgraded to `updater@2.6.0` + +## \[2.0.19] + +### Dependencies + +- Upgraded to `log@2.2.3` +- Upgraded to `opener@2.2.6` + +## \[2.0.18] + +### Dependencies + +- Upgraded to `log@2.2.2` +- Upgraded to `updater@2.5.1` + +## \[2.0.17] + +### Dependencies + +- Upgraded to `updater@2.5.0` + +## \[2.0.16] + +### Dependencies + +- Upgraded to `clipboard-manager@2.2.1` +- Upgraded to `http@2.3.0` +- Upgraded to `log@2.2.1` +- Upgraded to `updater@2.4.0` + +## \[2.0.15] + +### Dependencies + +- Upgraded to `haptics@2.2.3` +- Upgraded to `geolocation@2.2.3` +- Upgraded to `opener@2.2.5` + +## \[2.0.14] + +### Dependencies + +- Upgraded to `geolocation@2.2.2` +- Upgraded to `haptics@2.2.2` +- Upgraded to `notification@2.2.1` +- Upgraded to `opener@2.2.4` + +## \[2.0.13] + +### Dependencies + +- Upgraded to `geolocation@2.2.1` +- Upgraded to `haptics@2.2.1` + +## \[2.0.12] + +### Dependencies + +- Upgraded to `opener@2.2.3` +- Upgraded to `updater@2.3.1` + +## \[2.0.11] + +### Dependencies + +- Upgraded to `opener@2.2.2` + +## \[2.0.10] + +### Dependencies + +- Upgraded to `updater@2.3.0` +- Upgraded to `opener@2.2.1` + +## \[2.0.9] + +### Dependencies + +- Upgraded to `barcode-scanner@2.1.0` +- Upgraded to `biometric@2.1.0` +- Upgraded to `cli@2.1.0` +- Upgraded to `clipboard-manager@2.1.0` +- Upgraded to `dialog@2.1.0` +- Upgraded to `fs@2.2.0` +- Upgraded to `geolocation@2.1.0` +- Upgraded to `global-shortcut@2.1.0` +- Upgraded to `haptics@2.1.0` +- Upgraded to `http@2.1.0` +- Upgraded to `log@2.1.0` +- Upgraded to `nfc@2.1.0` +- Upgraded to `notification@2.1.0` +- Upgraded to `opener@2.1.0` +- Upgraded to `os@2.1.0` +- Upgraded to `process@2.1.0` +- Upgraded to `shell@2.1.0` +- Upgraded to `store@2.2.0` +- Upgraded to `updater@2.2.0` + +## \[2.0.8] + +### Dependencies + +- Upgraded to `fs@2.1.1` +- Upgraded to `dialog@2.0.5` +- Upgraded to `http@2.0.5` + +## \[2.0.7] + +### Dependencies + +- Upgraded to `log@2.0.4` + +## \[2.0.6] + +### Dependencies + +- Upgraded to `fs@2.1.0` +- Upgraded to `updater@2.1.0` +- Upgraded to `dialog@2.0.4` +- Upgraded to `log-plugin@2.0.3` +- Upgraded to `http@2.0.4` +- Upgraded to `opener@2.0.0` + +## \[2.0.5] + +### Dependencies + +- Upgraded to `clipboard-manager@2.0.2` +- Upgraded to `log-plugin@2.0.2` + +## \[2.0.4] + +### Dependencies + +- Upgraded to `fs@2.0.3` +- Upgraded to `dialog@2.0.3` +- Upgraded to `http@2.0.3` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `dialog@2.0.2` +- Upgraded to `fs@2.0.2` +- Upgraded to `http@2.0.2` +- Upgraded to `shell@2.0.2` +- Upgraded to `store@2.1.0` + +## \[2.0.2] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.1` +- Upgraded to `biometric@2.0.1` +- Upgraded to `cli@2.0.1` +- Upgraded to `clipboard-manager@2.0.1` +- Upgraded to `fs@2.0.1` +- Upgraded to `dialog@2.0.1` +- Upgraded to `geolocation@2.0.1` +- Upgraded to `global-shortcut@2.0.1` +- Upgraded to `haptics@2.0.1` +- Upgraded to `http@2.0.1` +- Upgraded to `log-plugin@2.0.1` +- Upgraded to `nfc@2.0.1` +- Upgraded to `notification@2.0.1` +- Upgraded to `os@2.0.1` +- Upgraded to `process@2.0.1` +- Upgraded to `shell@2.0.1` +- Upgraded to `store@2.0.1` +- Upgraded to `updater@2.0.2` + +## \[2.0.1] + +### Dependencies + +- Upgraded to `updater@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0` +- Upgraded to `biometric@2.0.0` +- Upgraded to `cli@2.0.0` +- Upgraded to `clipboard-manager@2.0.0` +- Upgraded to `fs@2.0.0` +- Upgraded to `dialog@2.0.0` +- Upgraded to `global-shortcut@2.0.0` +- Upgraded to `http@2.0.0` +- Upgraded to `log-plugin@2.0.0` +- Upgraded to `nfc@2.0.0` +- Upgraded to `notification@2.0.0` +- Upgraded to `os@2.0.0` +- Upgraded to `process@2.0.0` +- Upgraded to `shell@2.0.0` +- Upgraded to `store@2.0.0` +- Upgraded to `updater@2.0.0` + +## \[2.0.0-rc.8] + +### Dependencies + +- Upgraded to `cli@2.0.0-rc.2` +- Upgraded to `dialog@2.0.0-rc.8` +- Upgraded to `fs@2.0.0-rc.6` +- Upgraded to `shell@2.0.0-rc.4` +- Upgraded to `store@2.0.0-rc.4` +- Upgraded to `updater@2.0.0-rc.4` +- Upgraded to `http@2.0.0-rc.6` + +## \[2.0.0-rc.7] + +### Dependencies + +- Upgraded to `clipboard-manager@2.0.0-rc.4` +- Upgraded to `fs@2.0.0-rc.5` +- Upgraded to `notification@2.0.0-rc.5` +- Upgraded to `dialog@2.0.0-rc.7` +- Upgraded to `http@2.0.0-rc.5` + +## \[2.0.0-rc.6] + +### Dependencies + +- Upgraded to `dialog@2.0.0-rc.6` +- Upgraded to `fs@2.0.0-rc.4` +- Upgraded to `http@2.0.0-rc.4` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-rc.4` +- Upgraded to `notification@2.0.0-rc.4` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.3` +- Upgraded to `dialog@2.0.0-rc.5` +- Upgraded to `updater@2.0.0-rc.3` +- Upgraded to `http@2.0.0-rc.3` + +## \[2.0.0-rc.3] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.2` +- Upgraded to `dialog@2.0.0-rc.4` +- Upgraded to `http@2.0.0-rc.2` + +## \[2.0.0-rc.2] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-rc.3` +- Upgraded to `notification@2.0.0-rc.3` +- Upgraded to `dialog@2.0.0-rc.3` +- Upgraded to `fs@2.0.0-rc.1` +- Upgraded to `global-shortcut@2.0.0-rc.2` +- Upgraded to `store@2.0.0-rc.3` +- Upgraded to `biometric@2.0.0-rc.3` +- Upgraded to `cli@2.0.0-rc.1` +- Upgraded to `clipboard-manager@2.0.0-rc.3` +- Upgraded to `http@2.0.0-rc.1` +- Upgraded to `log-plugin@2.0.0-rc.2` +- Upgraded to `nfc@2.0.0-rc.3` +- Upgraded to `os@2.0.0-rc.1` +- Upgraded to `process@2.0.0-rc.1` +- Upgraded to `shell@2.0.0-rc.3` +- Upgraded to `updater@2.0.0-rc.2` + +## \[2.0.0-rc.1] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-rc.2` +- Upgraded to `biometric@2.0.0-rc.2` +- Upgraded to `clipboard-manager@2.0.0-rc.2` +- Upgraded to `dialog@2.0.0-rc.2` +- Upgraded to `log-plugin@2.0.0-rc.1` +- Upgraded to `nfc@2.0.0-rc.2` +- Upgraded to `notification@2.0.0-rc.2` +- Upgraded to `shell@2.0.0-rc.2` + +## \[2.0.0-rc.0] + +### Dependencies + +- Upgraded to `dialog@2.0.0-rc.1` +- Upgraded to `updater@2.0.0-rc.1` +- Upgraded to `barcode-scanner@2.0.0-rc.1` +- Upgraded to `clipboard-manager@2.0.0-rc.1` +- Upgraded to `global-shortcut@2.0.0-rc.1` +- Upgraded to `biometric@2.0.0-rc.1` +- Upgraded to `nfc@2.0.0-rc.1` +- Upgraded to `notification@2.0.0-rc.1` +- Upgraded to `shell@2.0.0-rc.1` + +## \[2.0.0-beta.17] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-rc.0` +- Upgraded to `biometric@2.0.0-rc.0` +- Upgraded to `cli@2.0.0-rc.0` +- Upgraded to `clipboard-manager@2.0.0-rc.0` +- Upgraded to `dialog@2.0.0-rc.0` +- Upgraded to `fs@2.0.0-rc.0` +- Upgraded to `global-shortcut@2.0.0-rc.0` +- Upgraded to `http@2.0.0-rc.0` +- Upgraded to `log-plugin@2.0.0-rc.0` +- Upgraded to `nfc@2.0.0-rc.0` +- Upgraded to `notification@2.0.0-rc.0` +- Upgraded to `os@2.0.0-rc.0` +- Upgraded to `process@2.0.0-rc.0` +- Upgraded to `shell@2.0.0-rc.0` +- Upgraded to `updater@2.0.0-rc.0` + +## \[2.0.0-beta.16] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.12` +- Upgraded to `barcode-scanner@2.0.0-beta.10` +- Upgraded to `biometric@2.0.0-beta.9` +- Upgraded to `cli@2.0.0-beta.9` +- Upgraded to `clipboard-manager@2.1.0-beta.7` +- Upgraded to `dialog@2.0.0-beta.12` +- Upgraded to `global-shortcut@2.0.0-beta.9` +- Upgraded to `http@2.0.0-beta.13` +- Upgraded to `log-plugin@2.0.0-beta.10` +- Upgraded to `nfc@2.0.0-beta.9` +- Upgraded to `notification@2.0.0-beta.12` +- Upgraded to `os@2.0.0-beta.9` +- Upgraded to `process@2.0.0-beta.9` +- Upgraded to `shell@2.0.0-beta.10` +- Upgraded to `updater@2.0.0-beta.12` + +## \[2.0.0-beta.15] + +### Dependencies + +- Upgraded to `log-plugin@2.0.0-beta.9` + +## \[2.0.0-beta.14] + +### Dependencies + +- Upgraded to `notification@2.0.0-beta.11` +- Upgraded to `updater@2.0.0-beta.11` + +## \[2.0.0-beta.13] + +### Dependencies + +- Upgraded to `biometric@2.0.0-beta.8` +- Upgraded to `global-shortcut@2.0.0-beta.8` +- Upgraded to `http@2.0.0-beta.12` +- Upgraded to `barcode-scanner@2.0.0-beta.9` +- Upgraded to `cli@2.0.0-beta.8` +- Upgraded to `clipboard-manager@2.1.0-beta.6` +- Upgraded to `dialog@2.0.0-beta.11` +- Upgraded to `fs@2.0.0-beta.11` +- Upgraded to `log-plugin@2.0.0-beta.8` +- Upgraded to `nfc@2.0.0-beta.8` +- Upgraded to `notification@2.0.0-beta.10` +- Upgraded to `os@2.0.0-beta.8` +- Upgraded to `process@2.0.0-beta.8` +- Upgraded to `shell@2.0.0-beta.9` +- Upgraded to `updater@2.0.0-beta.10` + +## \[2.0.0-beta.12] + +### Dependencies + +- Upgraded to `clipboard-manager@2.1.0-beta.5` +- Upgraded to `fs@2.0.0-beta.10` +- Upgraded to `updater@2.0.0-beta.9` +- Upgraded to `notification@2.0.0-beta.9` +- Upgraded to `os@2.0.0-beta.7` +- Upgraded to `barcode-scanner@2.0.0-beta.8` +- Upgraded to `biometric@2.0.0-beta.7` +- Upgraded to `cli@2.0.0-beta.7` +- Upgraded to `dialog@2.0.0-beta.10` +- Upgraded to `global-shortcut@2.0.0-beta.7` +- Upgraded to `http@2.0.0-beta.11` +- Upgraded to `log-plugin@2.0.0-beta.7` +- Upgraded to `nfc@2.0.0-beta.7` +- Upgraded to `process@2.0.0-beta.7` +- Upgraded to `shell@2.0.0-beta.8` + +## \[2.0.0-beta.11] + +### Dependencies + +- Upgraded to `notification@2.0.0-beta.8` +- Upgraded to `http@2.0.0-beta.10` +- Upgraded to `updater@2.0.0-beta.8` + +## \[2.0.0-beta.10] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-beta.7` +- Upgraded to `biometric@2.0.0-beta.6` +- Upgraded to `clipboard-manager@2.1.0-beta.4` +- Upgraded to `nfc@2.0.0-beta.6` +- Upgraded to `notification@2.0.0-beta.7` +- Upgraded to `shell@2.0.0-beta.7` +- Upgraded to `cli@2.0.0-beta.6` +- Upgraded to `dialog@2.0.0-beta.9` +- Upgraded to `fs@2.0.0-beta.9` +- Upgraded to `global-shortcut@2.0.0-beta.6` +- Upgraded to `http@2.0.0-beta.9` +- Upgraded to `log-plugin@2.0.0-beta.6` +- Upgraded to `os@2.0.0-beta.6` +- Upgraded to `process@2.0.0-beta.6` +- Upgraded to `updater@2.0.0-beta.7` + +## \[2.0.0-beta.9] + +### Dependencies + +- Upgraded to `clipboard-manager@2.1.0-beta.3` +- Upgraded to `dialog@2.0.0-beta.8` +- Upgraded to `http@2.0.0-beta.8` +- Upgraded to `notification@2.0.0-beta.6` +- Upgraded to `shell@2.0.0-beta.6` +- Upgraded to `barcode-scanner@2.0.0-beta.6` +- Upgraded to `biometric@2.0.0-beta.5` +- Upgraded to `nfc@2.0.0-beta.5` +- Upgraded to `cli@2.0.0-beta.5` +- Upgraded to `fs@2.0.0-beta.8` +- Upgraded to `global-shortcut@2.0.0-beta.5` +- Upgraded to `log-plugin@2.0.0-beta.5` +- Upgraded to `os@2.0.0-beta.5` +- Upgraded to `process@2.0.0-beta.5` +- Upgraded to `updater@2.0.0-beta.6` + +## \[2.0.0-beta.8] + +### Dependencies + +- Upgraded to `shell@2.0.0-beta.5` + +## \[2.0.0-beta.7] + +### Dependencies + +- Upgraded to `clipboard-manager@2.1.0-beta.2` +- Upgraded to `global-shortcut@2.0.0-beta.4` +- Upgraded to `barcode-scanner@2.0.0-beta.5` +- Upgraded to `biometric@2.0.0-beta.4` +- Upgraded to `cli@2.0.0-beta.4` +- Upgraded to `dialog@2.0.0-beta.7` +- Upgraded to `fs@2.0.0-beta.7` +- Upgraded to `http@2.0.0-beta.7` +- Upgraded to `log-plugin@2.0.0-beta.4` +- Upgraded to `nfc@2.0.0-beta.4` +- Upgraded to `notification@2.0.0-beta.5` +- Upgraded to `os@2.0.0-beta.4` +- Upgraded to `process@2.0.0-beta.4` +- Upgraded to `shell@2.0.0-beta.4` +- Upgraded to `updater@2.0.0-beta.5` + +## \[2.0.0-beta.6] + +### Dependencies + +- Upgraded to `notification@2.0.0-beta.4` +- Upgraded to `barcode-scanner@2.0.0-beta.4` +- Upgraded to `dialog@2.0.0-beta.6` +- Upgraded to `fs@2.0.0-beta.6` +- Upgraded to `http@2.0.0-beta.6` + +## \[2.0.0-beta.5] + +### Dependencies + +- Upgraded to `clipboard-manager@2.1.0-beta.1` +- Upgraded to `http@2.0.0-beta.5` +- Upgraded to `updater@2.0.0-beta.4` +- Upgraded to `dialog@2.0.0-beta.5` +- Upgraded to `fs@2.0.0-beta.5` + +## \[2.0.0-beta.4] + +### Dependencies + +- Upgraded to `dialog@2.0.0-beta.4` +- Upgraded to `fs@2.0.0-beta.4` +- Upgraded to `http@2.0.0-beta.4` + +## \[2.0.0-beta.3] + +### Dependencies + +- Upgraded to `clipboard-manager@2.1.0-beta.0` +- Upgraded to `dialog@2.0.0-beta.3` +- Upgraded to `fs@2.0.0-beta.3` +- Upgraded to `http@2.0.0-beta.3` +- Upgraded to `updater@2.0.0-beta.3` +- Upgraded to `barcode-scanner@2.0.0-beta.3` +- Upgraded to `biometric@2.0.0-beta.3` +- Upgraded to `cli@2.0.0-beta.3` +- Upgraded to `global-shortcut@2.0.0-beta.3` +- Upgraded to `log-plugin@2.0.0-beta.3` +- Upgraded to `nfc@2.0.0-beta.3` +- Upgraded to `notification@2.0.0-beta.3` +- Upgraded to `os@2.0.0-beta.3` +- Upgraded to `process@2.0.0-beta.3` +- Upgraded to `shell@2.0.0-beta.3` + +## \[2.0.0-beta.2] + +### Dependencies + +- Upgraded to `clipboard-manager@2.0.0-beta.2` +- Upgraded to `dialog@2.0.0-beta.2` +- Upgraded to `http@2.0.0-beta.2` +- Upgraded to `fs@2.0.0-beta.2` +- Upgraded to `shell@2.0.0-beta.2` +- Upgraded to `barcode-scanner@2.0.0-beta.2` +- Upgraded to `biometric@2.0.0-beta.2` +- Upgraded to `cli@2.0.0-beta.2` +- Upgraded to `global-shortcut@2.0.0-beta.2` +- Upgraded to `log-plugin@2.0.0-beta.2` +- Upgraded to `nfc@2.0.0-beta.2` +- Upgraded to `notification@2.0.0-beta.2` +- Upgraded to `os@2.0.0-beta.2` +- Upgraded to `process@2.0.0-beta.2` +- Upgraded to `updater@2.0.0-beta.2` + +## \[2.0.0-beta.1] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-beta.1` +- Upgraded to `biometric@2.0.0-beta.1` +- Upgraded to `cli@2.0.0-beta.1` +- Upgraded to `clipboard-manager@2.0.0-beta.1` +- Upgraded to `dialog@2.0.0-beta.1` +- Upgraded to `fs@2.0.0-beta.1` +- Upgraded to `global-shortcut@2.0.0-beta.1` +- Upgraded to `http@2.0.0-beta.1` +- Upgraded to `log-plugin@2.0.0-beta.1` +- Upgraded to `nfc@2.0.0-beta.1` +- Upgraded to `notification@2.0.0-beta.1` +- Upgraded to `os@2.0.0-beta.1` +- Upgraded to `process@2.0.0-beta.1` +- Upgraded to `shell@2.0.0-beta.1` +- Upgraded to `updater@2.0.0-beta.1` + +## \[2.0.0-beta.0] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-beta.0` +- Upgraded to `biometric@2.0.0-beta.0` +- Upgraded to `cli@2.0.0-beta.0` +- Upgraded to `clipboard-manager@2.0.0-beta.0` +- Upgraded to `dialog@2.0.0-beta.0` +- Upgraded to `fs@2.0.0-beta.0` +- Upgraded to `global-shortcut@2.0.0-beta.0` +- Upgraded to `http@2.0.0-beta.0` +- Upgraded to `log@2.0.0-beta.0` +- Upgraded to `nfc@2.0.0-beta.0` +- Upgraded to `notification@2.0.0-beta.0` +- Upgraded to `os@2.0.0-beta.0` +- Upgraded to `process@2.0.0-beta.0` +- Upgraded to `shell@2.0.0-beta.0` +- Upgraded to `updater@2.0.0-beta.0` + +## \[2.0.0-alpha.11] + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.7` +- Upgraded to `dialog@2.0.0-alpha.7` +- Upgraded to `http@2.0.0-alpha.9` + +## \[2.0.0-alpha.10] + +### Dependencies + +- Upgraded to `http@2.0.0-alpha.8` + +## \[2.0.0-alpha.9] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-alpha.4` +- Upgraded to `cli@2.0.0-alpha.6` +- Upgraded to `clipboard-manager@2.0.0-alpha.6` +- Upgraded to `dialog@2.0.0-alpha.6` +- Upgraded to `fs@2.0.0-alpha.6` +- Upgraded to `global-shortcut@2.0.0-alpha.6` +- Upgraded to `http@2.0.0-alpha.7` +- Upgraded to `log-plugin@2.0.0-alpha.6` +- Upgraded to `notification@2.0.0-alpha.7` +- Upgraded to `os@2.0.0-alpha.6` +- Upgraded to `process@2.0.0-alpha.6` +- Upgraded to `shell@2.0.0-alpha.6` +- Upgraded to `updater@2.0.0-alpha.6` +- Upgraded to `biometric@2.0.0-alpha.0` +- Upgraded to `nfc@2.0.0-alpha.0` + +## \[2.0.0-alpha.8] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-alpha.3` +- Upgraded to `cli@2.0.0-alpha.5` +- Upgraded to `clipboard-manager@2.0.0-alpha.5` +- Upgraded to `dialog@2.0.0-alpha.5` +- Upgraded to `fs@2.0.0-alpha.5` +- Upgraded to `global-shortcut@2.0.0-alpha.5` +- Upgraded to `http@2.0.0-alpha.6` +- Upgraded to `log-plugin@2.0.0-alpha.5` +- Upgraded to `notification@2.0.0-alpha.6` +- Upgraded to `os@2.0.0-alpha.5` +- Upgraded to `process@2.0.0-alpha.5` +- Upgraded to `shell@2.0.0-alpha.5` +- Upgraded to `updater@2.0.0-alpha.5` + +## \[2.0.0-alpha.7] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-alpha.2` +- Upgraded to `cli@2.0.0-alpha.4` +- Upgraded to `clipboard-manager@2.0.0-alpha.4` +- Upgraded to `dialog@2.0.0-alpha.4` +- Upgraded to `fs@2.0.0-alpha.4` +- Upgraded to `global-shortcut@2.0.0-alpha.4` +- Upgraded to `http@2.0.0-alpha.5` +- Upgraded to `log-plugin@2.0.0-alpha.4` +- Upgraded to `notification@2.0.0-alpha.5` +- Upgraded to `os@2.0.0-alpha.4` +- Upgraded to `process@2.0.0-alpha.4` +- Upgraded to `shell@2.0.0-alpha.4` +- Upgraded to `updater@2.0.0-alpha.4` + +## \[2.0.0-alpha.6] + +### Dependencies + +- Upgraded to `log-plugin@2.0.0-alpha.3` +- Upgraded to `shell@2.0.0-alpha.3` +- Upgraded to `dialog@2.0.0-alpha.3` +- Upgraded to `notification@2.0.0-alpha.4` +- Upgraded to `updater@2.0.0-alpha.3` +- Upgraded to `global-shortcut@2.0.0-alpha.3` +- Upgraded to `barcode-scanner@2.0.0-alpha.1` +- Upgraded to `cli@2.0.0-alpha.3` +- Upgraded to `clipboard-manager@2.0.0-alpha.3` +- Upgraded to `fs@2.0.0-alpha.3` +- Upgraded to `http@2.0.0-alpha.4` +- Upgraded to `os@2.0.0-alpha.3` +- Upgraded to `process@2.0.0-alpha.3` + +## \[2.0.0-alpha.5] + +### Dependencies + +- Upgraded to `barcode-scanner@2.0.0-alpha.0` + +## \[2.0.0-alpha.4] + +### Dependencies + +- Upgraded to `http@2.0.0-alpha.3` +- Upgraded to `app@2.0.0-alpha.2` +- Upgraded to `cli@2.0.0-alpha.2` +- Upgraded to `clipboard-manager@2.0.0-alpha.2` +- Upgraded to `dialog@2.0.0-alpha.2` +- Upgraded to `fs@2.0.0-alpha.2` +- Upgraded to `global-shortcut@2.0.0-alpha.2` +- Upgraded to `log-plugin@2.0.0-alpha.2` +- Upgraded to `notification@2.0.0-alpha.3` +- Upgraded to `os@2.0.0-alpha.2` +- Upgraded to `process@2.0.0-alpha.2` +- Upgraded to `shell@2.0.0-alpha.2` +- Upgraded to `updater@2.0.0-alpha.2` +- Upgraded to `window@2.0.0-alpha.2` + +## \[2.0.0-alpha.3] + +### Dependencies + +- Upgraded to `http@2.0.0-alpha.2` + +## \[2.0.0-alpha.2] + +### Dependencies + +- Upgraded to `dialog@2.0.0-alpha.1` +- Upgraded to `window@2.0.0-alpha.1` +- Upgraded to `app@2.0.0-alpha.1` +- Upgraded to `cli@2.0.0-alpha.1` +- Upgraded to `fs@2.0.0-alpha.1` +- Upgraded to `global-shortcut@2.0.0-alpha.1` +- Upgraded to `http@2.0.0-alpha.1` +- Upgraded to `log-plugin@2.0.0-alpha.1` +- Upgraded to `notification@2.0.0-alpha.2` +- Upgraded to `os@2.0.0-alpha.1` +- Upgraded to `process@2.0.0-alpha.1` +- Upgraded to `shell@2.0.0-alpha.1` +- Upgraded to `updater@2.0.0-alpha.1` + +## \[2.0.0-alpha.1] + +### Dependencies + +- Updated to latest `notification` + +## \[2.0.0-alpha.0] + +### Dependencies + +- Plugins v2 alpha. diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml new file mode 100644 index 0000000..db762fc --- /dev/null +++ b/examples/api/src-tauri/Cargo.toml @@ -0,0 +1,88 @@ +[package] +name = "api" +publish = false +version = "2.0.49" +description = "An example Tauri Application showcasing the api" +edition = "2021" +rust-version = { workspace = true } +license = "Apache-2.0 OR MIT" + +[lib] +name = "api_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { workspace = true, features = ["codegen", "isolation"] } + +[dependencies] +serde_json = { workspace = true } +serde = { workspace = true } +tiny_http = "0.12" +time = "0.3" +log = { workspace = true } +tauri-plugin-log = { path = "../../../plugins/log", version = "2.9.2" } +tauri-plugin-fs = { path = "../../../plugins/fs", version = "2.5.2", features = [ + "watch", +] } +tauri-plugin-clipboard-manager = { path = "../../../plugins/clipboard-manager", version = "2.3.3" } +tauri-plugin-dialog = { path = "../../../plugins/dialog", version = "2.7.3" } +tauri-plugin-http = { path = "../../../plugins/http", features = [ + "multipart", + "cookies", +], version = "2.7.0" } +tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.4.0", features = [ + "windows7-compat", +] } +tauri-plugin-os = { path = "../../../plugins/os", version = "2.3.2" } +tauri-plugin-process = { path = "../../../plugins/process", version = "2.3.1" } +tauri-plugin-opener = { path = "../../../plugins/opener", version = "2.5.5" } +tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.6" } +tauri-plugin-store = { path = "../../../plugins/store", version = "2.4.5" } +tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" } +tauri-plugin-deep-link = { path = "../../../plugins/deep-link", version = "2.4.10" } +tauri-plugin-sql = { path = "../../../plugins/sql", version = "2.4.1", features = [ + "sqlite", +] } +tauri-plugin-stronghold = { path = "../../../plugins/stronghold", version = "2.3.2" } +tauri-plugin-websocket = { path = "../../../plugins/websocket", version = "2.4.3" } + +# WebDriver automation bridge, used by the plugins e2e suite (packages/api-e2e). +# Desktop-only and behind the off-by-default `automation` feature so it never ships in a +# regular build. +[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] +tauri-plugin-automation = { version = "0.2.0", optional = true } + +[dependencies.tauri] +workspace = true +features = [ + "wry", + "common-controls-v6", + "x11", + "image-ico", + "image-png", + "isolation", + "macos-private-api", + "tray-icon", + "protocol-asset", +] + +[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] +tauri-plugin-autostart = { path = "../../../plugins/autostart", version = "2.5.1" } +tauri-plugin-cli = { path = "../../../plugins/cli", version = "2.4.1" } +tauri-plugin-global-shortcut = { path = "../../../plugins/global-shortcut", version = "2.3.2" } +tauri-plugin-positioner = { path = "../../../plugins/positioner", version = "2.3.4", features = [ + "tray-icon", +] } +tauri-plugin-updater = { path = "../../../plugins/updater", version = "2.12.0" } +tauri-plugin-window-state = { path = "../../../plugins/window-state", version = "2.2.0" } + +[target."cfg(any(target_os = \"android\", target_os = \"ios\"))".dependencies] +tauri-plugin-barcode-scanner = { path = "../../../plugins/barcode-scanner/", version = "2.4.6" } +tauri-plugin-nfc = { path = "../../../plugins/nfc", version = "2.3.6" } +tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.3" } +tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = "2.3.3" } +tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "2.3.3" } + +[features] +# Enables the WebDriver automation bridge; set by the e2e suite's build (packages/api-e2e). +automation = ["dep:tauri-plugin-automation"] diff --git a/examples/api/src-tauri/Cross.toml b/examples/api/src-tauri/Cross.toml new file mode 100644 index 0000000..23ac27b --- /dev/null +++ b/examples/api/src-tauri/Cross.toml @@ -0,0 +1,11 @@ +[build.env] +# must set ICONS_VOLUME, DIST_VOLUME and ISOLATION_VOLUME environment variables +# ICONS_VOLUME: absolute path to the icons folder +# DIST_VOLUME: absolute path to the dist folder +# ISOLATION_VOLUME: absolute path to the isolation dist folder +# this can be done running `$ . .setup-cross.sh` in the examples/api folder +volumes = ["ICONS_VOLUME", "DIST_VOLUME", "ISOLATION_VOLUME"] + +[target.aarch64-unknown-linux-gnu] +image = "aarch64-unknown-linux-gnu:latest" +#image = "ghcr.io/tauri-apps/tauri/aarch64-unknown-linux-gnu:latest" diff --git a/examples/api/src-tauri/Info.plist b/examples/api/src-tauri/Info.plist new file mode 100644 index 0000000..4dcd6ac --- /dev/null +++ b/examples/api/src-tauri/Info.plist @@ -0,0 +1,14 @@ + + + + + NSCameraUsageDescription + Request camera access for WebRTC + NSMicrophoneUsageDescription + Request microphone access for WebRTC + NSFaceIDUsageDescription + Authenticate with biometrics + NFCReaderUsageDescription + Read and write to NFC tags for testing + + diff --git a/examples/api/src-tauri/build.rs b/examples/api/src-tauri/build.rs new file mode 100644 index 0000000..88537dd --- /dev/null +++ b/examples/api/src-tauri/build.rs @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +fn main() { + tauri_build::build(); +} diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json new file mode 100644 index 0000000..90c6d97 --- /dev/null +++ b/examples/api/src-tauri/capabilities/base.json @@ -0,0 +1,148 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "run-app-base", + "description": "Base permissions to run the app", + "windows": ["main"], + "permissions": [ + "log:default", + { + "identifier": "http:default", + "allow": [ + "https://tauri.app", + { + "url": "http://localhost:3003" + } + ] + }, + "core:default", + "core:app:allow-set-app-theme", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-set-size", + "core:window:allow-close", + "core:webview:allow-create-webview-window", + "core:window:allow-start-dragging", + "notification:default", + "os:default", + "os:allow-hostname", + "dialog:default", + { + "identifier": "shell:allow-spawn", + "allow": [ + { + "name": "sh", + "cmd": "sh", + "args": [ + "-c", + { + "validator": ".+" + } + ] + }, + { + "name": "cmd", + "cmd": "cmd", + "args": [ + "/C", + { + "validator": ".+" + } + ] + } + ] + }, + { + "identifier": "shell:allow-execute", + "allow": [ + { + "name": "sh", + "cmd": "sh", + "args": [ + "-c", + { + "validator": ".+" + } + ] + }, + { + "name": "cmd", + "cmd": "cmd", + "args": [ + "/C", + { + "validator": ".+" + } + ] + } + ] + }, + "shell:default", + "shell:allow-kill", + "shell:allow-stdin-write", + "process:allow-exit", + "process:allow-restart", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", + "clipboard-manager:allow-read-image", + "clipboard-manager:allow-write-image", + "clipboard-manager:allow-write-html", + "clipboard-manager:allow-clear", + "fs:default", + "fs:read-meta", + "fs:allow-open", + "fs:allow-write", + "fs:allow-read", + "fs:allow-rename", + "fs:allow-mkdir", + "fs:allow-remove", + "fs:allow-write-text-file", + "fs:allow-write-file", + "fs:allow-create", + "fs:allow-copy-file", + "fs:allow-seek", + "fs:allow-truncate", + "fs:allow-ftruncate", + "fs:allow-unwatch", + "fs:scope-download-recursive", + "fs:scope-resource-recursive", + { + "identifier": "fs:scope-appdata-recursive", + "allow": [ + { + "path": "$APPDATA/db/" + }, + { + "path": "$APPDATA/db/**" + } + ], + "deny": ["$APPDATA/db/*.stronghold"] + }, + "store:default", + "opener:default", + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "https://*", + "app": "inAppBrowser" + } + ] + }, + { + "identifier": "opener:allow-open-path", + "allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }] + }, + "upload:default", + "deep-link:default", + "deep-link:allow-register", + "deep-link:allow-unregister", + "deep-link:allow-is-registered", + "sql:default", + "sql:allow-execute", + "stronghold:default", + "stronghold:allow-destroy", + "stronghold:allow-remove-secret", + "stronghold:allow-remove-store-record", + "websocket:default" + ] +} diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..26722c2 --- /dev/null +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -0,0 +1,19 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "run-app-desktop", + "description": "Permissions to run the app (desktop only)", + "windows": ["main"], + "platforms": ["linux", "macOS", "windows"], + "permissions": [ + "autostart:default", + "cli:default", + "positioner:default", + "updater:default", + "global-shortcut:allow-unregister", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister-all", + "global-shortcut:allow-is-registered", + "window-state:default", + { "identifier": "fs:allow-watch", "allow": ["*", "**/*"] } + ] +} diff --git a/examples/api/src-tauri/capabilities/mobile.json b/examples/api/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000..da77f5e --- /dev/null +++ b/examples/api/src-tauri/capabilities/mobile.json @@ -0,0 +1,24 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "run-app-mobile", + "description": "Permissions to run the app (mobile only)", + "windows": ["main"], + "platforms": ["android", "iOS"], + "permissions": [ + "nfc:allow-write", + "nfc:allow-scan", + "biometric:allow-authenticate", + "barcode-scanner:allow-scan", + "barcode-scanner:allow-cancel", + "barcode-scanner:allow-request-permissions", + "barcode-scanner:allow-check-permissions", + "geolocation:allow-check-permissions", + "geolocation:allow-request-permissions", + "geolocation:allow-watch-position", + "geolocation:allow-get-current-position", + "haptics:allow-impact-feedback", + "haptics:allow-notification-feedback", + "haptics:allow-selection-feedback", + "haptics:allow-vibrate" + ] +} diff --git a/examples/api/src-tauri/gen/android/.editorconfig b/examples/api/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.gitignore b/examples/api/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..b248203 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/.gitignore b/examples/api/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..4008dd7 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/java/com/tauri/api/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/build.gradle.kts b/examples/api/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..029d0f4 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,69 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "com.tauri.api" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.tauri.api" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.6.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.8.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/proguard-rules.pro b/examples/api/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8ebe7c4 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/api/src-tauri/gen/android/app/src/main/java/com/tauri/api/MainActivity.kt b/examples/api/src-tauri/gen/android/app/src/main/java/com/tauri/api/MainActivity.kt new file mode 100644 index 0000000..e89f632 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/java/com/tauri/api/MainActivity.kt @@ -0,0 +1,11 @@ +package com.tauri.api + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/examples/api/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/examples/api/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/examples/api/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/examples/api/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/examples/api/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4042aae --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/values/colors.xml b/examples/api/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/values/strings.xml b/examples/api/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6f4305e --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Tauri API + Tauri API + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/values/themes.xml b/examples/api/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4042aae --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/examples/api/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/examples/api/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/examples/api/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/api/src-tauri/gen/android/build.gradle.kts b/examples/api/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/examples/api/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/examples/api/src-tauri/gen/android/buildSrc/build.gradle.kts b/examples/api/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/examples/api/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/BuildTask.kt b/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/BuildTask.kt new file mode 100644 index 0000000..f764e2a --- /dev/null +++ b/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/RustPlugin.kt b/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/examples/api/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/api/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/gradle.properties b/examples/api/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/examples/api/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/examples/api/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/examples/api/src-tauri/gen/android/gradlew b/examples/api/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/examples/api/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/examples/api/src-tauri/gen/android/gradlew.bat b/examples/api/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/examples/api/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/api/src-tauri/gen/android/settings.gradle b/examples/api/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/examples/api/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/examples/api/src-tauri/gen/apple/.gitignore b/examples/api/src-tauri/gen/apple/.gitignore new file mode 100644 index 0000000..6726e2f --- /dev/null +++ b/examples/api/src-tauri/gen/apple/.gitignore @@ -0,0 +1,3 @@ +xcuserdata/ +build/ +Externals/ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000..a6ac2a8 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000..cf265a4 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000..29c9746 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e4adcbc Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..704c929 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2a9fbc2 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2cdf184 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4723e4b Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f26fee4 Binary files /dev/null and b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/examples/api/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "AppIcon-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppIcon-512@2x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/examples/api/src-tauri/gen/apple/Assets.xcassets/Contents.json b/examples/api/src-tauri/gen/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/examples/api/src-tauri/gen/apple/ExportOptions.plist b/examples/api/src-tauri/gen/apple/ExportOptions.plist new file mode 100644 index 0000000..0428a17 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + debugging + + diff --git a/examples/api/src-tauri/gen/apple/LaunchScreen.storyboard b/examples/api/src-tauri/gen/apple/LaunchScreen.storyboard new file mode 100644 index 0000000..81b5f90 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/LaunchScreen.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/api/src-tauri/gen/apple/Podfile b/examples/api/src-tauri/gen/apple/Podfile new file mode 100644 index 0000000..f4d3303 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project + +target 'api_iOS' do +platform :ios, '15.0' + # Pods for api_iOS +end + +target 'api_macOS' do +platform :osx, '11.0' + # Pods for api_macOS +end + +# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' + end + end +end diff --git a/examples/api/src-tauri/gen/apple/Sources/api/bindings/bindings.h b/examples/api/src-tauri/gen/apple/Sources/api/bindings/bindings.h new file mode 100644 index 0000000..5152200 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/Sources/api/bindings/bindings.h @@ -0,0 +1,8 @@ +#pragma once + +namespace ffi { + extern "C" { + void start_app(); + } +} + diff --git a/examples/api/src-tauri/gen/apple/Sources/api/main.mm b/examples/api/src-tauri/gen/apple/Sources/api/main.mm new file mode 100644 index 0000000..7793a9d --- /dev/null +++ b/examples/api/src-tauri/gen/apple/Sources/api/main.mm @@ -0,0 +1,6 @@ +#include "bindings/bindings.h" + +int main(int argc, char * argv[]) { + ffi::start_app(); + return 0; +} diff --git a/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e01e3a6 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj @@ -0,0 +1,469 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 3043432501C9BC2DB6B4CB95 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71EB788DE4662CFC0D97F567 /* CoreGraphics.framework */; }; + 328B4ADB3700C1873BEB7B10 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 90D3B673AFAB8D8AB561F616 /* main.mm */; }; + 6F379F15DA085785BA2624D4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B7E79E23E646BA7968B457C /* Assets.xcassets */; }; + 832F9A55FEDEF3D807D8C40A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 248286BAA086BB1A5F98B2B2 /* libapp.a */; }; + 9AADB041D25772D04E543F15 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62601E25FA39E62BE119B74D /* Metal.framework */; }; + 9DDA3BE70DD0E4013973FE38 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6082E363D51372A7658C351 /* UIKit.framework */; }; + AC8BDC2C7A63FA3FDC5967F4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D1B108AE002010BDEC6D2 /* LaunchScreen.storyboard */; }; + AFA0CA286325FD7A34968CA2 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384966E551417F94A02D2706 /* Security.framework */; }; + B60763BD194DFACA215EC7DA /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC377692DC31A070A0188C9D /* QuartzCore.framework */; }; + C6D80743F168BDF017B7769E /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 59CFE20DCF760BE67D9CE3D6 /* WebKit.framework */; }; + DFFF888045C8D9D9FB69E8FD /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 338E66700FD330B99D434DD7 /* MetalKit.framework */; }; + F86717F05E27C72C9FA1FB27 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 74A8FDFB350B966F5AAD4A24 /* assets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0E96CE07CD20273DD46BF325 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; + 1C1AB1B414CA2795AFBEDDB9 /* tray.rs */ = {isa = PBXFileReference; path = tray.rs; sourceTree = ""; }; + 248286BAA086BB1A5F98B2B2 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + 2F63E2AA460089BB58D40C79 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 338E66700FD330B99D434DD7 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + 384966E551417F94A02D2706 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 4B2D1B108AE002010BDEC6D2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 59CFE20DCF760BE67D9CE3D6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 5AC703CEBA41A121596066F3 /* api_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = api_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 62601E25FA39E62BE119B74D /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + 6B7E79E23E646BA7968B457C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 71EB788DE4662CFC0D97F567 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 74A8FDFB350B966F5AAD4A24 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + 785D025E9542F7E098BF22B5 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; + 879941AE3DAA14534BBC6391 /* api_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = api_iOS.entitlements; sourceTree = ""; }; + 90D3B673AFAB8D8AB561F616 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + B6082E363D51372A7658C351 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + DC377692DC31A070A0188C9D /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + EC8C7948C50C3C9B5D96CB61 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + F835F52713CE8F029D5D252C /* cmd.rs */ = {isa = PBXFileReference; path = cmd.rs; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 11E18DCDB3ADFE87C18915EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 832F9A55FEDEF3D807D8C40A /* libapp.a in Frameworks */, + 3043432501C9BC2DB6B4CB95 /* CoreGraphics.framework in Frameworks */, + 9AADB041D25772D04E543F15 /* Metal.framework in Frameworks */, + DFFF888045C8D9D9FB69E8FD /* MetalKit.framework in Frameworks */, + B60763BD194DFACA215EC7DA /* QuartzCore.framework in Frameworks */, + AFA0CA286325FD7A34968CA2 /* Security.framework in Frameworks */, + 9DDA3BE70DD0E4013973FE38 /* UIKit.framework in Frameworks */, + C6D80743F168BDF017B7769E /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0677CEAF1F282F38CBA0F140 = { + isa = PBXGroup; + children = ( + 74A8FDFB350B966F5AAD4A24 /* assets */, + 6B7E79E23E646BA7968B457C /* Assets.xcassets */, + 4B2D1B108AE002010BDEC6D2 /* LaunchScreen.storyboard */, + F2116A6428EED18BE2A07E2B /* api_iOS */, + 86D903732E10FAC4D300E8DF /* Externals */, + 7A9A7AC155D9E22E54D6D847 /* Sources */, + CF9AA87D2F6E9C389B7AB70B /* src */, + 10C9FC3FA3E12D6A4A67999D /* Frameworks */, + 4AC51E67B71E27F15B02C5CD /* Products */, + ); + sourceTree = ""; + }; + 07051859D6E2D8109C8FB128 /* bindings */ = { + isa = PBXGroup; + children = ( + EC8C7948C50C3C9B5D96CB61 /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + 10C9FC3FA3E12D6A4A67999D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 71EB788DE4662CFC0D97F567 /* CoreGraphics.framework */, + 248286BAA086BB1A5F98B2B2 /* libapp.a */, + 62601E25FA39E62BE119B74D /* Metal.framework */, + 338E66700FD330B99D434DD7 /* MetalKit.framework */, + DC377692DC31A070A0188C9D /* QuartzCore.framework */, + 384966E551417F94A02D2706 /* Security.framework */, + B6082E363D51372A7658C351 /* UIKit.framework */, + 59CFE20DCF760BE67D9CE3D6 /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 4AC51E67B71E27F15B02C5CD /* Products */ = { + isa = PBXGroup; + children = ( + 5AC703CEBA41A121596066F3 /* api_iOS.app */, + ); + name = Products; + sourceTree = ""; + }; + 7A9A7AC155D9E22E54D6D847 /* Sources */ = { + isa = PBXGroup; + children = ( + A3574F52DBC5463B9C3D043D /* api */, + ); + path = Sources; + sourceTree = ""; + }; + 86D903732E10FAC4D300E8DF /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; + A3574F52DBC5463B9C3D043D /* api */ = { + isa = PBXGroup; + children = ( + 90D3B673AFAB8D8AB561F616 /* main.mm */, + 07051859D6E2D8109C8FB128 /* bindings */, + ); + path = api; + sourceTree = ""; + }; + CF9AA87D2F6E9C389B7AB70B /* src */ = { + isa = PBXGroup; + children = ( + F835F52713CE8F029D5D252C /* cmd.rs */, + 785D025E9542F7E098BF22B5 /* lib.rs */, + 0E96CE07CD20273DD46BF325 /* main.rs */, + 1C1AB1B414CA2795AFBEDDB9 /* tray.rs */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; + F2116A6428EED18BE2A07E2B /* api_iOS */ = { + isa = PBXGroup; + children = ( + 879941AE3DAA14534BBC6391 /* api_iOS.entitlements */, + 2F63E2AA460089BB58D40C79 /* Info.plist */, + ); + path = api_iOS; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 54DC6E273C78071F3BA12EF3 /* api_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 01CBC40275452376830D79B1 /* Build configuration list for PBXNativeTarget "api_iOS" */; + buildPhases = ( + FF948951157DE71465B5BD5F /* Build Rust Code */, + 71E73CC9AB5F1323EC1F6365 /* Sources */, + CA2BEC44B6EDA1F21B6155CD /* Resources */, + 11E18DCDB3ADFE87C18915EF /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = api_iOS; + packageProductDependencies = ( + ); + productName = api_iOS; + productReference = 5AC703CEBA41A121596066F3 /* api_iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9BC88C3717DA5D4B78A51C15 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 54DC6E273C78071F3BA12EF3 = { + DevelopmentTeam = Q93MBH6S2F; + }; + }; + }; + buildConfigurationList = 8FA67D0F928A09CD639137D1 /* Build configuration list for PBXProject "api" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 0677CEAF1F282F38CBA0F140; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 54; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 54DC6E273C78071F3BA12EF3 /* api_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CA2BEC44B6EDA1F21B6155CD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6F379F15DA085785BA2624D4 /* Assets.xcassets in Resources */, + AC8BDC2C7A63FA3FDC5967F4 /* LaunchScreen.storyboard in Resources */, + F86717F05E27C72C9FA1FB27 /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + FF948951157DE71465B5BD5F /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 71E73CC9AB5F1323EC1F6365 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 328B4ADB3700C1873BEB7B10 /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A83F70B4C02DD0222038C7F1 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + B6AD77E490F315562F75D3D7 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + BF284FE6E7AE0C8DDCCE398B /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = "Q93MBH6S2F"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = api_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.tauri.api; + PRODUCT_NAME = "Tauri API"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = debug; + }; + DB0E254D0FD84970B57F6410 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = "Q93MBH6S2F"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = api_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.tauri.api; + PRODUCT_NAME = "Tauri API"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 01CBC40275452376830D79B1 /* Build configuration list for PBXNativeTarget "api_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BF284FE6E7AE0C8DDCCE398B /* debug */, + DB0E254D0FD84970B57F6410 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + 8FA67D0F928A09CD639137D1 /* Build configuration list for PBXProject "api" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B6AD77E490F315562F75D3D7 /* debug */, + A83F70B4C02DD0222038C7F1 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9BC88C3717DA5D4B78A51C15 /* Project object */; +} diff --git a/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..ac90d5a --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + DisableBuildSystemDeprecationDiagnostic + + + diff --git a/examples/api/src-tauri/gen/apple/api.xcodeproj/xcshareddata/xcschemes/api_iOS.xcscheme b/examples/api/src-tauri/gen/apple/api.xcodeproj/xcshareddata/xcschemes/api_iOS.xcscheme new file mode 100644 index 0000000..2a9f504 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api.xcodeproj/xcshareddata/xcschemes/api_iOS.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/api/src-tauri/gen/apple/api_iOS/Info.plist b/examples/api/src-tauri/gen/apple/api_iOS/Info.plist new file mode 100644 index 0000000..7d35e70 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api_iOS/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2.0.0 + CFBundleVersion + 2.0.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSCameraUsageDescription + Request camera access for WebRTC + NSMicrophoneUsageDescription + Request microphone access for WebRTC + NSFaceIDUsageDescription + Authenticate with biometrics + NFCReaderUsageDescription + Read and write to NFC tags for testing + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements b/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/api/src-tauri/gen/apple/project.yml b/examples/api/src-tauri/gen/apple/project.yml new file mode 100644 index 0000000..55c95ef --- /dev/null +++ b/examples/api/src-tauri/gen/apple/project.yml @@ -0,0 +1,89 @@ +name: api +options: + bundleIdPrefix: com.tauri.api + deploymentTarget: + iOS: 15.0 +fileGroups: [../../src] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: Tauri API + PRODUCT_BUNDLE_IDENTIFIER: com.tauri.api + DEVELOPMENT_TEAM: Q93MBH6S2F +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + api_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Assets.xcassets + - path: Externals + - path: api_iOS + - path: assets + buildPhase: resources + type: folder + - path: LaunchScreen.storyboard + info: + path: api_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: 2.0.0 + CFBundleVersion: 2.0.0 + entitlements: + path: api_iOS/api_iOS.entitlements + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + base: + ENABLE_BITCODE: false + ARCHS: [arm64] + VALID_ARCHS: arm64 + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: libapp.a + embed: false + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + - sdk: WebKit.framework + preBuildScripts: + - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a \ No newline at end of file diff --git a/examples/api/src-tauri/icons/128x128.png b/examples/api/src-tauri/icons/128x128.png new file mode 100644 index 0000000..77e7d23 Binary files /dev/null and b/examples/api/src-tauri/icons/128x128.png differ diff --git a/examples/api/src-tauri/icons/128x128@2x.png b/examples/api/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0f7976f Binary files /dev/null and b/examples/api/src-tauri/icons/128x128@2x.png differ diff --git a/examples/api/src-tauri/icons/32x32.png b/examples/api/src-tauri/icons/32x32.png new file mode 100644 index 0000000..98fda06 Binary files /dev/null and b/examples/api/src-tauri/icons/32x32.png differ diff --git a/examples/api/src-tauri/icons/icon.ico b/examples/api/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/examples/api/src-tauri/icons/icon.ico differ diff --git a/examples/api/src-tauri/icons/icon.png b/examples/api/src-tauri/icons/icon.png new file mode 100644 index 0000000..d1756ce Binary files /dev/null and b/examples/api/src-tauri/icons/icon.png differ diff --git a/examples/api/src-tauri/icons/tray_icon.png b/examples/api/src-tauri/icons/tray_icon.png new file mode 100644 index 0000000..e037c51 Binary files /dev/null and b/examples/api/src-tauri/icons/tray_icon.png differ diff --git a/examples/api/src-tauri/icons/tray_icon_with_transparency.ico b/examples/api/src-tauri/icons/tray_icon_with_transparency.ico new file mode 100644 index 0000000..b399024 Binary files /dev/null and b/examples/api/src-tauri/icons/tray_icon_with_transparency.ico differ diff --git a/examples/api/src-tauri/icons/tray_icon_with_transparency.png b/examples/api/src-tauri/icons/tray_icon_with_transparency.png new file mode 100644 index 0000000..99d8ff2 Binary files /dev/null and b/examples/api/src-tauri/icons/tray_icon_with_transparency.png differ diff --git a/examples/api/src-tauri/locales/pt-BR.wxl b/examples/api/src-tauri/locales/pt-BR.wxl new file mode 100644 index 0000000..8f58c07 --- /dev/null +++ b/examples/api/src-tauri/locales/pt-BR.wxl @@ -0,0 +1,6 @@ + + Executar Tauri API + Uma versão mais recente de Tauri API está instalada. + Adiciona o caminho do executável de Tauri API para a variável de ambiente PATH. Isso permite Tauri API ser executado pela linha de comando. + Instala Tauri API. + diff --git a/examples/api/src-tauri/src/cmd.rs b/examples/api/src-tauri/src/cmd.rs new file mode 100644 index 0000000..734552c --- /dev/null +++ b/examples/api/src-tauri/src/cmd.rs @@ -0,0 +1,24 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; +use tauri::command; + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct RequestBody { + id: i32, + name: String, +} + +#[command] +pub fn log_operation(event: String, payload: Option) { + log::info!("{} {:?}", event, payload); +} + +#[command] +pub fn perform_request(endpoint: String, body: RequestBody) -> String { + println!("{} {:?}", endpoint, body); + "message response".into() +} diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs new file mode 100644 index 0000000..9a20067 --- /dev/null +++ b/examples/api/src-tauri/src/lib.rs @@ -0,0 +1,223 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +mod cmd; +#[cfg(desktop)] +mod tray; + +use serde::Serialize; +use tauri::{ + webview::{PageLoadEvent, WebviewWindowBuilder}, + App, AppHandle, Emitter, Listener, Manager, RunEvent, WebviewUrl, +}; + +#[derive(Clone, Serialize)] +struct Reply { + data: String, +} + +pub type SetupHook = Box Result<(), Box> + Send>; +pub type OnEvent = Box; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[allow(unused_mut)] + let mut builder = tauri::Builder::default(); + + // WebDriver automation bridge for the plugins e2e suite (packages/api-e2e). + // Registered as early as possible per the plugin's docs, behind the + // off-by-default `automation` feature. + #[cfg(all(desktop, feature = "automation"))] + { + builder = builder.plugin(tauri_plugin_automation::init()); + } + + #[allow(unused_mut)] + let mut builder = builder + .plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + // forward records to the webview so `attachLogger`/`attachConsole` work + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Webview, + )) + .build(), + ) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_upload::init()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_websocket::init()) + .plugin( + tauri_plugin_sql::Builder::new() + .add_migrations( + "sqlite:api.db", + vec![tauri_plugin_sql::Migration { + version: 1, + description: "create_todos_table", + sql: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0);", + kind: tauri_plugin_sql::MigrationKind::Up, + }], + ) + .build(), + ) + .setup(move |app| { + // the argon2 salt lives next to the snapshots the frontend creates + let local_data_dir = app.path().app_local_data_dir()?; + std::fs::create_dir_all(&local_data_dir)?; + app.handle().plugin( + tauri_plugin_stronghold::Builder::with_argon2(&local_data_dir.join("salt.txt")) + .build(), + )?; + + #[cfg(desktop)] + { + // registered before the tray, whose events it tracks + app.handle().plugin(tauri_plugin_positioner::init())?; + tray::create_tray(app.handle())?; + app.handle().plugin(tauri_plugin_cli::init())?; + app.handle().plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + ))?; + app.handle() + .plugin(tauri_plugin_global_shortcut::Builder::new().build())?; + app.handle() + .plugin(tauri_plugin_window_state::Builder::new().build())?; + app.handle() + .plugin(tauri_plugin_updater::Builder::new().build())?; + } + #[cfg(mobile)] + { + app.handle().plugin(tauri_plugin_barcode_scanner::init())?; + app.handle().plugin(tauri_plugin_nfc::init())?; + app.handle().plugin(tauri_plugin_biometric::init())?; + app.handle().plugin(tauri_plugin_geolocation::init())?; + app.handle().plugin(tauri_plugin_haptics::init())?; + } + + let mut webview_window_builder = + WebviewWindowBuilder::new(app, "main", WebviewUrl::default()); + #[cfg(desktop)] + { + webview_window_builder = webview_window_builder + .user_agent(&format!("Tauri API - {}", std::env::consts::OS)) + .title("Tauri API Validation") + .inner_size(1000., 800.) + .min_inner_size(600., 400.) + .visible(false); + } + + #[cfg(target_os = "windows")] + { + webview_window_builder = webview_window_builder + .transparent(true) + .shadow(true) + .decorations(false); + } + + #[cfg(target_os = "macos")] + { + webview_window_builder = webview_window_builder.transparent(true); + } + + let _webview = webview_window_builder.build().unwrap(); + + std::thread::spawn(|| { + let server = match tiny_http::Server::http("localhost:3003") { + Ok(s) => s, + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + }; + loop { + if let Ok(mut request) = server.recv() { + let mut body = Vec::new(); + let _ = request.as_reader().read_to_end(&mut body); + let mut headers = request.headers().to_vec(); + + if !headers.iter().any(|header| header.field == tiny_http::HeaderField::from_bytes(b"Cookie").unwrap()) { + let expires = time::OffsetDateTime::now_utc() + time::Duration::days(1); + // RFC 1123 format + let format = time::macros::format_description!( + "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT" + ); + let expires_str = expires.format(format).unwrap(); + headers.push( + tiny_http::Header::from_bytes( + &b"Set-Cookie"[..], + format!("session-token=test-value; Secure; Path=/; Expires={expires_str}") + .as_bytes(), + ) + .unwrap(), + ); + } + + let response = tiny_http::Response::new( + tiny_http::StatusCode(200), + headers, + std::io::Cursor::new(body), + request.body_length(), + None, + ); + let _ = request.respond(response); + } + } + }); + + Ok(()) + }) + .on_page_load(|webview, payload| { + if payload.event() == PageLoadEvent::Finished { + let webview_ = webview.clone(); + webview.listen("js-event", move |event| { + println!("got js-event with message '{:?}'", event.payload()); + let reply = Reply { + data: "something else".to_string(), + }; + + webview_ + .emit("rust-event", Some(reply)) + .expect("failed to emit"); + }); + } + }); + + #[cfg(target_os = "macos")] + { + builder = builder.menu(tauri::menu::Menu::default); + } + + #[allow(unused_mut)] + let mut app = builder + .invoke_handler(tauri::generate_handler![ + cmd::log_operation, + cmd::perform_request, + ]) + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Regular); + + app.run(move |_app_handle, _event| { + #[cfg(desktop)] + if let RunEvent::ExitRequested { code, api, .. } = &_event { + if code.is_none() { + // Keep the event loop running even if all windows are closed + // This allow us to catch system tray events when there is no window + api.prevent_exit(); + } + } + }) +} diff --git a/examples/api/src-tauri/src/main.rs b/examples/api/src-tauri/src/main.rs new file mode 100644 index 0000000..bbed006 --- /dev/null +++ b/examples/api/src-tauri/src/main.rs @@ -0,0 +1,10 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + api_lib::run(); +} diff --git a/examples/api/src-tauri/src/tray.rs b/examples/api/src-tauri/src/tray.rs new file mode 100644 index 0000000..ba4a5a0 --- /dev/null +++ b/examples/api/src-tauri/src/tray.rs @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, Runtime, WebviewUrl, WebviewWindowBuilder, +}; + +pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { + let toggle_i = MenuItem::with_id(app, "toggle", "Toggle", true, None::<&str>)?; + let new_window_i = MenuItem::with_id(app, "new-window", "New window", true, None::<&str>)?; + let icon_i_1 = MenuItem::with_id(app, "icon-1", "Icon 1", true, None::<&str>)?; + let icon_i_2 = MenuItem::with_id(app, "icon-2", "Icon 2", true, None::<&str>)?; + #[cfg(target_os = "macos")] + let set_title_i = MenuItem::with_id(app, "set-title", "Set Title", true, None::<&str>)?; + let switch_i = MenuItem::with_id(app, "switch-menu", "Switch Menu", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let remove_tray_i = + MenuItem::with_id(app, "remove-tray", "Remove Tray icon", true, None::<&str>)?; + let menu1 = Menu::with_items( + app, + &[ + &toggle_i, + &new_window_i, + &icon_i_1, + &icon_i_2, + #[cfg(target_os = "macos")] + &set_title_i, + &switch_i, + &quit_i, + &remove_tray_i, + ], + )?; + let menu2 = Menu::with_items( + app, + &[&toggle_i, &new_window_i, &switch_i, &quit_i, &remove_tray_i], + )?; + + let is_menu1 = AtomicBool::new(true); + + let _ = TrayIconBuilder::with_id("tray-1") + .tooltip("Tauri") + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu1) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id.as_ref() { + "quit" => { + app.exit(0); + } + "remove-tray" => { + app.remove_tray_by_id("tray-1"); + } + "toggle" => { + if let Some(window) = app.get_webview_window("main") { + let new_title = if window.is_visible().unwrap_or_default() { + let _ = window.hide(); + "Show" + } else { + let _ = window.show(); + let _ = window.set_focus(); + "Hide" + }; + toggle_i.set_text(new_title).unwrap(); + } + } + "new-window" => { + let _ = WebviewWindowBuilder::new(app, "new", WebviewUrl::App("index.html".into())) + .title("Tauri") + .build(); + } + #[cfg(target_os = "macos")] + "set-title" => { + if let Some(tray) = app.tray_by_id("tray-1") { + let _ = tray.set_title(Some("Tauri")); + } + } + i @ "icon-1" | i @ "icon-2" => { + if let Some(tray) = app.tray_by_id("tray-1") { + let _ = tray.set_icon(Some(if i == "icon-1" { + tauri::image::Image::from_bytes(include_bytes!("../icons/icon.ico")) + .unwrap() + } else { + tauri::image::Image::from_bytes(include_bytes!( + "../icons/tray_icon_with_transparency.png" + )) + .unwrap() + })); + } + } + "switch-menu" => { + let flag = is_menu1.load(Ordering::Relaxed); + let (menu, tooltip) = if flag { + (menu2.clone(), "Menu 2") + } else { + (menu1.clone(), "Tauri") + }; + if let Some(tray) = app.tray_by_id("tray-1") { + let _ = tray.set_menu(Some(menu)); + let _ = tray.set_tooltip(Some(tooltip)); + } + is_menu1.store(!flag, Ordering::Relaxed); + } + + _ => {} + }) + .on_tray_icon_event(|tray, event| { + // lets the positioner plugin place windows relative to the tray icon + tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event); + if let TrayIconEvent::Click { + button_state: MouseButtonState::Down, + button: MouseButton::Left, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app); + + Ok(()) +} diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json new file mode 100644 index 0000000..e30e336 --- /dev/null +++ b/examples/api/src-tauri/tauri.conf.json @@ -0,0 +1,113 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Tauri API", + "version": "2.0.0", + "identifier": "com.tauri.api", + "build": { + "devUrl": "http://localhost:5173", + "frontendDist": "../dist", + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build" + }, + "app": { + "withGlobalTauri": true, + "macOSPrivateApi": true, + "security": { + "pattern": { + "use": "isolation", + "options": { + "dir": "../isolation-dist/" + } + }, + "csp": { + "default-src": "'self' customprotocol: asset:", + "connect-src": "ipc: http://ipc.localhost", + "font-src": ["https://fonts.gstatic.com"], + "img-src": "'self' asset: http://asset.localhost blob: data:", + "style-src": "'unsafe-inline' 'self' http://fonts.googleapis.com" + }, + "freezePrototype": true, + "assetProtocol": { + "enable": true, + "scope": { + "allow": ["$APPDATA/db/**", "$RESOURCE/**"], + "deny": ["$APPDATA/db/*.stronghold"] + } + } + } + }, + "plugins": { + "cli": { + "description": "Tauri API example", + "args": [ + { + "short": "c", + "name": "config", + "takesValue": true, + "description": "Config path" + }, + { + "short": "t", + "name": "theme", + "takesValue": true, + "description": "App theme", + "possibleValues": ["light", "dark", "system"] + }, + { + "short": "v", + "name": "verbose", + "description": "Verbosity level" + } + ], + "subcommands": { + "update": { + "description": "Updates the app", + "args": [ + { + "short": "b", + "name": "background", + "description": "Update in background" + } + ] + } + } + }, + "shell": { + "open": true + }, + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK", + "dangerousInsecureTransportProtocol": true, + "endpoints": [ + "http://localhost:5173/updater-test/updater.json", + "https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}" + ] + } + }, + "bundle": { + "active": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "wix": { + "language": { + "en-US": {}, + "pt-BR": { + "localePath": "locales/pt-BR.wxl" + } + } + }, + "nsis": { + "compression": "none" + } + }, + "iOS": { + "minimumSystemVersion": "15.0" + } + } +} diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte new file mode 100644 index 0000000..573ff7e --- /dev/null +++ b/examples/api/src/App.svelte @@ -0,0 +1,503 @@ + + + + + +{#if isWindows} + +{/if} + + + + +
+ +
+
+

{selected.label}

+
+
+ +
+
+
+ +
+
+
+

Console

+ +
+
+ {#each $messages as messageHtml} + {@html messageHtml} + {/each} +
+
+
+
diff --git a/examples/api/src/app.css b/examples/api/src/app.css new file mode 100644 index 0000000..f411d6e --- /dev/null +++ b/examples/api/src/app.css @@ -0,0 +1,84 @@ +:root { + line-height: 1.5; +} + +:root.dark { + color-scheme: dark; +} +:root.light { + color-scheme: light; +} + +*:not(h1, h2, h3, h4, h5, h6) { + margin: 0; + padding: 0; +} + +* { + box-sizing: border-box; + font-family: 'Rubik', sans-serif; +} + +button { + color: inherit; + background-color: inherit; +} + +::-webkit-scrollbar { + width: 0.25rem; + height: 3px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: 0.25rem; +} + +code { + padding: 0.05rem 0.25rem; +} + +code.code-block { + padding: 0.5rem; +} + +#sidebar { + width: 18.75rem; +} + +@media screen and (width < 640px) { + #sidebar { + --translate-x: -18.75rem; + transform: translateX(var(--translate-x)); + } +} + +#sidebar, +main { + padding-top: calc(env(safe-area-inset-top) + 2rem); +} + +#sidebar { + padding-left: calc(env(safe-area-inset-left) + 0.5rem); +} + +main { + padding-right: env(safe-area-inset-right); +} + +#sidebarToggle { + margin-top: calc(env(safe-area-inset-top) + 0.5rem); + margin-left: calc(env(safe-area-inset-left) + 0.5rem); +} + +#sidebar, +#console { + padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem); +} + +.transparent { + background-color: transparent; +} diff --git a/examples/api/src/lib/TitleBar.svelte b/examples/api/src/lib/TitleBar.svelte new file mode 100644 index 0000000..c1e4253 --- /dev/null +++ b/examples/api/src/lib/TitleBar.svelte @@ -0,0 +1,103 @@ + + +
+ Tauri API Validation + + + + + + +
diff --git a/examples/api/src/lib/utils.js b/examples/api/src/lib/utils.js new file mode 100644 index 0000000..a5ddc05 --- /dev/null +++ b/examples/api/src/lib/utils.js @@ -0,0 +1,15 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +export function arrayBufferToBase64(buffer, callback) { + const blob = new Blob([buffer], { + type: 'application/octet-binary' + }) + const reader = new FileReader() + reader.onload = function (evt) { + const dataurl = evt.target.result + callback(dataurl.substr(dataurl.indexOf(',') + 1)) + } + reader.readAsDataURL(blob) +} diff --git a/examples/api/src/main.ts b/examples/api/src/main.ts new file mode 100644 index 0000000..c0e0e5f --- /dev/null +++ b/examples/api/src/main.ts @@ -0,0 +1,14 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import 'uno.css' +import './app.css' +import App from './App.svelte' +import { mount } from 'svelte' + +const app = mount(App, { + target: document.querySelector('#app')! +}) + +export default app diff --git a/examples/api/src/views/Autostart.svelte b/examples/api/src/views/Autostart.svelte new file mode 100644 index 0000000..1f8c557 --- /dev/null +++ b/examples/api/src/views/Autostart.svelte @@ -0,0 +1,30 @@ + + +
+ + Launching at login is {enabled ? 'enabled' : 'disabled'} +
diff --git a/examples/api/src/views/Biometric.svelte b/examples/api/src/views/Biometric.svelte new file mode 100644 index 0000000..3f287b0 --- /dev/null +++ b/examples/api/src/views/Biometric.svelte @@ -0,0 +1,30 @@ + + +
+ + +
+ diff --git a/examples/api/src/views/Cli.svelte b/examples/api/src/views/Cli.svelte new file mode 100644 index 0000000..06f38e0 --- /dev/null +++ b/examples/api/src/views/Cli.svelte @@ -0,0 +1,29 @@ + + +
+ This binary can be run from the terminal and takes the following arguments: + +
+  --config <PATH>
+  --theme <light|dark|system>
+  --verbose
+
+ Additionally, it has a update --background subcommand. +
+
+
+ Note that the arguments are only parsed, not implemented. +
+
+
+ diff --git a/examples/api/src/views/Clipboard.svelte b/examples/api/src/views/Clipboard.svelte new file mode 100644 index 0000000..2bc0fcc --- /dev/null +++ b/examples/api/src/views/Clipboard.svelte @@ -0,0 +1,68 @@ + + +
+ + + + +
diff --git a/examples/api/src/views/Communication.svelte b/examples/api/src/views/Communication.svelte new file mode 100644 index 0000000..f34dcd3 --- /dev/null +++ b/examples/api/src/views/Communication.svelte @@ -0,0 +1,52 @@ + + +
+ + + +
diff --git a/examples/api/src/views/DeepLink.svelte b/examples/api/src/views/DeepLink.svelte new file mode 100644 index 0000000..9a1a191 --- /dev/null +++ b/examples/api/src/views/DeepLink.svelte @@ -0,0 +1,67 @@ + + +
+
+ Current deep link: {current ? current.join(', ') : 'none'} +
+ +
+ + + + +
+
diff --git a/examples/api/src/views/Dialog.svelte b/examples/api/src/views/Dialog.svelte new file mode 100644 index 0000000..7c1b616 --- /dev/null +++ b/examples/api/src/views/Dialog.svelte @@ -0,0 +1,195 @@ + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + +
diff --git a/examples/api/src/views/FileSystem.svelte b/examples/api/src/views/FileSystem.svelte new file mode 100644 index 0000000..bc35547 --- /dev/null +++ b/examples/api/src/views/FileSystem.svelte @@ -0,0 +1,272 @@ + + +
+ {#if isMobile} +
+ On mobile, paths outside of App* paths require the use of dialogs + regardless of Tauri's scope mechanism. +
+
+ {/if} +
+ + +
+
+
+ + + + +
+ + +
+ +
+ {#if file} +
+ + + +
+ {/if} + +

Watch

+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+ + diff --git a/examples/api/src/views/Geolocation.svelte b/examples/api/src/views/Geolocation.svelte new file mode 100644 index 0000000..cd181df --- /dev/null +++ b/examples/api/src/views/Geolocation.svelte @@ -0,0 +1,29 @@ + + + diff --git a/examples/api/src/views/Haptics.svelte b/examples/api/src/views/Haptics.svelte new file mode 100644 index 0000000..9ddf15c --- /dev/null +++ b/examples/api/src/views/Haptics.svelte @@ -0,0 +1,46 @@ + + +
+ + + + + +
+ +
+ +

+ Depending on your device settings for haptic feedback some of the buttons may + not work. +

diff --git a/examples/api/src/views/Http.svelte b/examples/api/src/views/Http.svelte new file mode 100644 index 0000000..5c7c3c4 --- /dev/null +++ b/examples/api/src/views/Http.svelte @@ -0,0 +1,103 @@ + + +
+ +
+ +
+ +
+ +
+ +

HTTP Form

+ +
+ + +
+
+
+ +
+
+ diff --git a/examples/api/src/views/Nfc.svelte b/examples/api/src/views/Nfc.svelte new file mode 100644 index 0000000..4d7ea2c --- /dev/null +++ b/examples/api/src/views/Nfc.svelte @@ -0,0 +1,126 @@ + + +
+
+
+ + +
+ + +
+ + {#if isAndroid} +
+

Filters

+
+ + + +
+
+ +
+
+ {/if} + +
+

Write Records

+
+ + +
+
+ + +
diff --git a/examples/api/src/views/Notifications.svelte b/examples/api/src/views/Notifications.svelte new file mode 100644 index 0000000..11273fd --- /dev/null +++ b/examples/api/src/views/Notifications.svelte @@ -0,0 +1,44 @@ + + + + diff --git a/examples/api/src/views/Opener.svelte b/examples/api/src/views/Opener.svelte new file mode 100644 index 0000000..e20e765 --- /dev/null +++ b/examples/api/src/views/Opener.svelte @@ -0,0 +1,68 @@ + + +
+
+ + + with + +
+ +
+ + + with + +
+ +
+ + +
+
diff --git a/examples/api/src/views/Positioner.svelte b/examples/api/src/views/Positioner.svelte new file mode 100644 index 0000000..1f79167 --- /dev/null +++ b/examples/api/src/views/Positioner.svelte @@ -0,0 +1,40 @@ + + +
+

+ The tray positions only resolve once the tray icon has been clicked, which + reports its location to the plugin. +

+
+ + + +
+
diff --git a/examples/api/src/views/Scanner.svelte b/examples/api/src/views/Scanner.svelte new file mode 100644 index 0000000..ea0609e --- /dev/null +++ b/examples/api/src/views/Scanner.svelte @@ -0,0 +1,163 @@ + + +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+
+
+
+

Aim your camera at a QR code

+ +
+
+
+
+
+
+
+
+
+
+ + diff --git a/examples/api/src/views/Shell.svelte b/examples/api/src/views/Shell.svelte new file mode 100644 index 0000000..faaa8c4 --- /dev/null +++ b/examples/api/src/views/Shell.svelte @@ -0,0 +1,100 @@ + + +
+
+ Script: + +
+
+ Encoding: + +
+
+ Working directory: + +
+
+ Arguments: + +
+
+ + +
+ {#if child} +
+ + + {/if} +
diff --git a/examples/api/src/views/Shortcuts.svelte b/examples/api/src/views/Shortcuts.svelte new file mode 100644 index 0000000..41d0271 --- /dev/null +++ b/examples/api/src/views/Shortcuts.svelte @@ -0,0 +1,72 @@ + + +
+ + +
+
+
+ {#each $shortcuts as savedShortcut} +
+ {savedShortcut} + +
+ {/each} + {#if $shortcuts.length > 1} +
+ + {/if} +
diff --git a/examples/api/src/views/Sql.svelte b/examples/api/src/views/Sql.svelte new file mode 100644 index 0000000..45e656e --- /dev/null +++ b/examples/api/src/views/Sql.svelte @@ -0,0 +1,68 @@ + + +
+
+ + +
+ + {#each todos as todo (todo.id)} +
+ toggle(todo)} + /> + {todo.title} + +
+ {/each} +
diff --git a/examples/api/src/views/Store.svelte b/examples/api/src/views/Store.svelte new file mode 100644 index 0000000..6ba9a9a --- /dev/null +++ b/examples/api/src/views/Store.svelte @@ -0,0 +1,114 @@ + + +
+
+
+ Key: + +
+ +
+ Value: + +
+ +
+ + + + + +
+
Store at {path} on disk
+
+ +
+

Store Values

+ {#each Object.entries(cache) as [k, v]} +
{k} = {v}
+ {/each} +
+
diff --git a/examples/api/src/views/Stronghold.svelte b/examples/api/src/views/Stronghold.svelte new file mode 100644 index 0000000..900b2bb --- /dev/null +++ b/examples/api/src/views/Stronghold.svelte @@ -0,0 +1,71 @@ + + +
+ {#if stronghold} +
+ + + +
+ {:else} +
+ + +
+ {/if} +
diff --git a/examples/api/src/views/Updater.svelte b/examples/api/src/views/Updater.svelte new file mode 100644 index 0000000..5fdc775 --- /dev/null +++ b/examples/api/src/views/Updater.svelte @@ -0,0 +1,98 @@ + + +
+ {#if !isChecking && !newUpdate} + + {:else if !isInstalling && newUpdate} + + {:else} +
+ {progress}% +
+
+ {/if} +
+ + diff --git a/examples/api/src/views/Upload.svelte b/examples/api/src/views/Upload.svelte new file mode 100644 index 0000000..0eeb13f --- /dev/null +++ b/examples/api/src/views/Upload.svelte @@ -0,0 +1,417 @@ + + +
+
+

File Download

+ +
+
+ + +
+ +
+ +
+ + +
+
+ + {#if downloadPath} +
+
+ File will be saved as: +
{downloadPath}
+
+
+ {/if} + + + + {#if downloadProgress} +
+
+ Progress: {downloadProgress.percentage}% + Speed: {Math.round(downloadProgress.transferSpeed / 1024)} KB/s +
+
+
+
+
+ {Math.round(downloadProgress.progressTotal / 1024)} KB / {Math.round(downloadProgress.total / 1024)} KB +
+
+ {/if} + + {#if downloadResult} +
+ +
+ {/if} +
+
+ +
+

File Upload

+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +

Choose the HTTP method for the upload request

+
+ +
+
+ Upload Configuration: +
+ Method: {uploadMethod} | URL: {uploadUrl || 'Not set'} +
+
+
+ + + + {#if uploadProgress} +
+
+ Progress: {uploadProgress.percentage}% + Speed: {Math.round(uploadProgress.transferSpeed / 1024)} KB/s +
+
+
+
+
+ {Math.round(uploadProgress.progressTotal / 1024)} KB / {Math.round(uploadProgress.total / 1024)} KB +
+
+ {/if} + + {#if uploadResult} +
+ +
+ {/if} +
+
+
diff --git a/examples/api/src/views/WebRTC.svelte b/examples/api/src/views/WebRTC.svelte new file mode 100644 index 0000000..e0f9862 --- /dev/null +++ b/examples/api/src/views/WebRTC.svelte @@ -0,0 +1,56 @@ + + +
+
Not available for Linux
+ +
diff --git a/examples/api/src/views/WebSocket.svelte b/examples/api/src/views/WebSocket.svelte new file mode 100644 index 0000000..9a91f89 --- /dev/null +++ b/examples/api/src/views/WebSocket.svelte @@ -0,0 +1,55 @@ + + +
+ {#if ws} +
+ + + +
+ {:else} +
+ + +
+ {/if} +
diff --git a/examples/api/src/views/Welcome.svelte b/examples/api/src/views/Welcome.svelte new file mode 100644 index 0000000..5d42aaa --- /dev/null +++ b/examples/api/src/views/Welcome.svelte @@ -0,0 +1,47 @@ + + +

+ This is a demo of Tauri's API capabilities using the @tauri-apps/api package. It's used as the main validation app, serving as the test bed of our + development process. In the future, this app will be used on Tauri's integration + tests. +

+ +
+
+
+App name: {appName}
+App version: {version}
+Tauri version: {tauriVersion}
+
+
+
+ + +
diff --git a/examples/api/tsconfig.json b/examples/api/tsconfig.json new file mode 100644 index 0000000..c315e05 --- /dev/null +++ b/examples/api/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "noEmit": true, + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true, + "types": ["vite/client", "svelte"] + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/examples/api/unocss.config.js b/examples/api/unocss.config.js new file mode 100644 index 0000000..d2069a4 --- /dev/null +++ b/examples/api/unocss.config.js @@ -0,0 +1,100 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { defineConfig, presetIcons, presetUno, presetWebFonts } from 'unocss' +import extractorSvelte from '@unocss/extractor-svelte' + +export default defineConfig({ + theme: { + colors: { + primary: '#FFFFFF', + primaryLighter: '#e9ecef', + darkPrimary: '#1B1B1D', + darkPrimaryLighter: '#242526', + primaryText: '#1C1E21', + darkPrimaryText: '#E3E3E3', + secondaryText: '#858A91', + darkSecondaryText: '#C2C5CA', + accent: '#3578E5', + accentDark: '#306cce', + accentDarker: '#2d66c3', + accentDarkest: '#2554a0', + accentLight: '#538ce9', + accentLighter: '#72a1ed', + accentLightest: '#9abcf2', + accentText: '#FFFFFF', + darkAccent: '#67d6ed', + darkAccentDark: '#49cee9', + darkAccentDarker: '#39cae8', + darkAccentDarkest: '#19b5d5', + darkAccentLight: '#85def1', + darkAccentLighter: '#95e2f2', + darkAccentLightest: '#c2eff8', + darkAccentText: '#1C1E21', + code: '#d6d8da', + codeDark: '#282a2e', + hoverOverlay: 'rgba(0,0,0,.05)', + hoverOverlayDarker: 'rgba(0,0,0,.1)', + darkHoverOverlay: 'hsla(0,0%,100%,.05)', + darkHoverOverlayDarker: 'hsla(0,0%,100%,.1)' + } + }, + preflights: [ + { + getCSS: ({ theme }) => ` + ::-webkit-scrollbar-thumb { + background-color: ${theme.colors.accent}; + } + + .dark ::-webkit-scrollbar-thumb { + background-color: ${theme.colors.darkAccent}; + } + + code { + font-size: ${theme.fontSize.xs[0]}; + font-family: ${theme.fontFamily.mono}; + border-radius: ${theme.borderRadius['DEFAULT']}; + background-color: ${theme.colors.code}; + } + + .code-block { + font-family: ${theme.fontFamily.mono}; + font-size: ${theme.fontSize.sm[0]}; + } + + .dark code { + background-color: ${theme.colors.codeDark}; + } + ` + } + ], + shortcuts: { + btn: `select-none outline-none shadow-md p-2 rd-1 text-primaryText border-none font-400 dark:font-600 + bg-accent hover:bg-accentDarker active:bg-accentDarkest text-accentText + dark:bg-darkAccent dark:hover:bg-darkAccentDarker dark:active:bg-darkAccentDarkest dark:text-darkAccentText`, + nv: `decoration-none flex items-center relative p-2 rd-1 transition-all-125 ease + text-darkSecondaryText + hover:text-accent dark:hover:text-darkAccent + hover:bg-darkHoverOverlay hover:border-l-4`, + nv_selected: `nv bg-darkHoverOverlay text-accent dark:text-darkAccent border-l-4`, + note: `decoration-none flex-inline items-center relative p-2 rd-1 + border-l-4 border-accent dark:border-darkAccent + bg-accent/10 dark:bg-darkAccent/10`, + 'note-red': + 'note bg-red-700/10 dark:bg-red-700/10 after:bg-red-700 dark:after:bg-red-700', + input: + 'h-10 flex items-center outline-none border-none p-2 rd-1 shadow-md bg-primaryLighter dark:bg-darkPrimaryLighter text-primaryText dark:text-darkPrimaryText' + }, + presets: [ + presetUno(), + presetIcons(), + presetWebFonts({ + fonts: { + sans: 'Rubik', + mono: ['Fira Code', 'Fira Mono:400,700'] + } + }) + ], + extractors: [extractorSvelte] +}) diff --git a/examples/api/updater-test/.gitignore b/examples/api/updater-test/.gitignore new file mode 100644 index 0000000..78bd45a --- /dev/null +++ b/examples/api/updater-test/.gitignore @@ -0,0 +1,5 @@ +* + +!.gitignore +!README.md +!updater.json diff --git a/examples/api/updater-test/README.md b/examples/api/updater-test/README.md new file mode 100644 index 0000000..2155130 --- /dev/null +++ b/examples/api/updater-test/README.md @@ -0,0 +1,10 @@ +To test the updater: + +1. Comment out `verify_signature` inside `plugins/updater/src/updater.rs` +2. Run `pnpm tauri build --debug` to build the bundles +3. Copy the bundle you want to test to this folder (`examples/api/updater-test/`) +4. Run `pnpm tauri dev` and open the Updater tab to check + +If the bundle you're testing doesn't exist in the `updater.json` yet, add it manually and welcome to open an PR + +> The `updater.json` and debug bundles will be served by `vite` diff --git a/examples/api/updater-test/updater.json b/examples/api/updater-test/updater.json new file mode 100644 index 0000000..84edf61 --- /dev/null +++ b/examples/api/updater-test/updater.json @@ -0,0 +1,11 @@ +{ + "version": "2.1.0", + "notes": "Test update!", + "pub_date": "2026-03-01T14:04:20+00:00", + "platforms": { + "windows-x86_64": { + "signature": "", + "url": "http://localhost:5173/updater-test/Tauri API_2.0.0_x64_en-US.msi" + } + } +} diff --git a/examples/api/vite.config.ts b/examples/api/vite.config.ts new file mode 100644 index 0000000..aab7232 --- /dev/null +++ b/examples/api/vite.config.ts @@ -0,0 +1,34 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { defineConfig } from 'vite' +import Unocss from 'unocss/vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import process from 'process' + +const host = process.env.TAURI_DEV_HOST + +// https://vitejs.dev/config/ +export default defineConfig(async () => { + return { + plugins: [Unocss(), svelte()], + build: { + rollupOptions: { + output: { + entryFileNames: `assets/[name].js`, + chunkFileNames: `assets/[name].js`, + assetFileNames: `assets/[name].[ext]` + } + } + }, + server: { + host: host || false, + port: 5173, + strictPort: true, + fs: { + allow: ['.'] + } + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..73d7977 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "plugins-workspace", + "private": true, + "license": "MIT OR Apache-2.0", + "type": "module", + "scripts": { + "build": "pnpm run -r --parallel --filter !plugins-workspace --filter !\"./plugins/*/examples/**\" --filter !\"./examples/*\" build", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "ts:check": "pnpm run -r ts:check", + "test:api-e2e": "pnpm run --filter \"api-e2e\" e2e", + "test:api-e2e:android": "pnpm run --filter \"api-e2e\" e2e:android", + "test:api-e2e:ios": "pnpm run --filter \"api-e2e\" e2e:ios", + "example:api:dev": "pnpm run --filter \"api\" tauri dev" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/plugin-terser": "1.0.0", + "@rollup/plugin-typescript": "12.3.0", + "eslint": "10.4.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-security": "4.0.1", + "prettier": "3.8.3", + "rollup": "4.63.1", + "tslib": "2.8.1", + "typescript": "6.0.3", + "typescript-eslint": "8.70.0" + }, + "minimumReleaseAge": 4320, + "packageManager": "pnpm@12.3.4" +} diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md new file mode 100644 index 0000000..e39fc17 --- /dev/null +++ b/packages/api-e2e/README.md @@ -0,0 +1,251 @@ +# Plugins end-to-end tests + +WebdriverIO suite that exercises the JavaScript API of the plugins in this +repository against a **real** Tauri app — the [`examples/api`](../../examples/api) +validation app — rather than a mocked backend, on desktop (Linux, macOS, Windows) +and mobile (Android, iOS). Each plugin has its own spec file, shared by every +platform, and adding coverage for a new plugin API is normally just dropping in +one more spec. + +It mirrors the [`@tauri-apps/api` e2e suite](https://github.com/tauri-apps/tauri/tree/dev/packages/api-e2e) +in the core repository; the `@tauri-apps/api` modules themselves are covered there. + +## How it works + +- The example app is built with `withGlobalTauri: true`, so the `@tauri-apps/api` surface + is reachable on `window.__TAURI__` inside the webview, and every plugin's `api-iife.js` + registers its API next to it (`window.__TAURI__.fs`, `window.__TAURI__.clipboardManager`, + …, the package name without the `@tauri-apps/plugin-` prefix, camel-cased). +- On desktop, WebdriverIO drives the app through [`@crabnebula/tauri-driver`](https://www.npmjs.com/package/@crabnebula/tauri-driver), + which bridges the WebDriver protocol to each platform's webview: + - **macOS** — the CrabNebula Webdriver, which needs [`tauri-plugin-automation`](https://crates.io/crates/tauri-plugin-automation) + (registered in `examples/api` behind its off-by-default `automation` Cargo feature, which + the suite's build enables) and a locally-running `@crabnebula/test-runner-backend`, + authenticated with `CN_API_KEY`. + - **Linux** — `webkit2gtk-driver` (`WebKitWebDriver` on `PATH`). + - **Windows** — `msedgedriver.exe` on `PATH`. It hands the app the `--remote-debugging-port` + it attaches to through `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, which WebView2 ignores in an + elevated process ([wry#1782](https://github.com/tauri-apps/wry/issues/1782)), so the suite has + to run unelevated. +- On mobile, WebdriverIO drives the app through [Appium](https://appium.io) (started by + `@wdio/appium-service`; the drivers are plain devDependencies of this package, which Appium + picks up on its own): + - **Android** — the UiAutomator2 driver. The suite switches to the app's `WEBVIEW_*` + context, which chromedriver reaches through the WebView's debugging socket. Debug builds + turn that on (`setWebContentsDebuggingEnabled`), so the suite builds a debug APK. A + chromedriver matching the device's WebView is downloaded on demand (see `E2E_CHROMEDRIVER`). + - **iOS** — the XCUITest driver on a simulator, attaching to the WKWebView through the + WebKit remote inspector. Debug builds mark the webview `isInspectable`, so the suite + builds an unsigned debug simulator app. The inspector identifies an app by the + `application-identifier` entitlement that Xcode embeds when it code signs a simulator + build; an unsigned one has none and is listed as `process-` instead of + its bundle identifier, so the config has the driver match that name too + (`appium:additionalWebviewBundleIds`). The driver also starts with a script timeout of + 0, which the config raises to the 30s the other drivers default to, or every + `executeAsync` would time out at once. +- Specs never `eval` in the page. They pass a function to the [`tauri()`](test/helpers/index.ts) + helper, which serializes it and runs it via the driver's own (CSP-exempt) script injection, + handing it `window.__TAURI__` as the first argument and returning its JSON result. +- Each spec file gets its own session — a fresh `tauri-driver` (and therefore a fresh app + instance) on desktop, a fresh Appium session (which relaunches the app) on mobile — so + each plugin's suite runs in isolation. +- A small [fixture server](test/helpers/server.ts) is started for the whole run. It serves + the updater manifest the desktop e2e build points the updater at (see + [`tauri.e2e.conf.json`](tauri.e2e.conf.json)) and the upload/download fixtures. It listens + on the host's loopback; the iOS simulator shares that network stack, and on Android the + mobile config runs `adb reverse` so the same `127.0.0.1` URL works on the device. The + `http` specs use the echo server the example app itself spawns on port 3003, since that + is the only `http://` origin in the example's http scope. The fixture server also has a + WebSocket echo endpoint (`ws://127.0.0.1:3004/ws`) for the `websocket` specs. + +## What is (not) covered + +The example only registers each plugin on the platforms it supports, so the suites +split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are +skipped on desktop, and the rest run everywhere with the odd test gated. + +| Plugin | Coverage | On mobile | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. | +| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. | +| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. | +| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. | +| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. | +| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). | +| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. | +| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. | +| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. | +| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. | +| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. | +| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. | +| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. | +| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. | +| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. | +| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. | +| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. | +| `stronghold` | Store records (persisted across reloads), wrong-password and unknown-client errors, vault secrets, BIP39/SLIP10 derivation and Ed25519 signing. | Same. | +| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. | +| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). | +| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). | +| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. | +| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. | + +The notification permission specs are desktop-only: a mobile app starts out ungranted and +`requestPermission` puts up a system dialog the session would then block on. The rest of the +notification API (action types, channels, the pending/active lists, listeners) only exists on +mobile, and is covered there without the permission. + +The [`plugins.spec.ts`](test/specs/plugins.spec.ts) spec additionally asserts that every +plugin the platform registers injects its global API with its documented members, and that +the _other_ platform's plugins are absent. That is what catches a plugin whose +`global_api_script_path` is missing from its `build.rs`, or a crate that is not target-gated +in the example's `Cargo.toml`. + +## Prerequisites + +```sh +# from the repo root +pnpm install +pnpm build # examples/api resolves the plugins' JS from their dist-js output +``` + +Platform driver dependencies: + +| Platform | Requirement | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS | `CN_API_KEY` env var (CrabNebula Cloud). The automation plugin and test-runner-backend are wired up already. | +| Linux | `webkit2gtk-driver` package (provides `WebKitWebDriver`). | +| Windows | `msedgedriver.exe` matching your Edge version, on `PATH`. Run the suite unelevated. | +| Android | The usual Tauri Android setup (`ANDROID_HOME`, `NDK_HOME`, a JDK), which also provides the `adb` the suite shells out to, plus a running emulator or a connected device with USB debugging. Network access the first time, for the chromedriver download. | +| iOS | macOS with Xcode and an iOS simulator runtime. The first session compiles WebDriverAgent (a few minutes). | + +## Running + +```sh +# desktop, from the repo root +pnpm test:api-e2e + +# or from this package +pnpm e2e + +# iterate without rebuilding the app every run +E2E_SKIP_BUILD=1 pnpm e2e + +# run a single plugin's spec +pnpm exec wdio run ./wdio.conf.ts --spec test/specs/fs.spec.ts + +# mobile (from the repo root; or `pnpm e2e:android` / `pnpm e2e:ios` from this package) +pnpm test:api-e2e:android +pnpm test:api-e2e:ios +``` + +The first desktop run builds the app with [`tauri.e2e.conf.json`](tauri.e2e.conf.json) as a +config override, which enables the example's `automation` feature and points the updater at +the fixture server; afterwards use `E2E_SKIP_BUILD=1` to reuse the existing binary. A binary +supplied through `E2E_SKIP_BUILD` or `E2E_APP_PATH` must have been built with that override: +the `updater` specs rely on its endpoint, and the CrabNebula Webdriver (always on macOS) +relies on the automation feature. + +The mobile configs ([`wdio.android.conf.ts`](wdio.android.conf.ts), [`wdio.ios.conf.ts`](wdio.ios.conf.ts), +sharing [`wdio.mobile.ts`](wdio.mobile.ts)) run `tauri android build --debug --apk` / +`tauri ios build --debug --target aarch64-sim --no-sign`, compiling only the Rust target the +device runs (the Android one is read from the connected device through `adb`). The Android +Studio and Xcode projects are committed under `examples/api/src-tauri/gen`, so they are only +initialized if that directory is missing. The app must be a **debug** build — release builds +have webview debugging off, and Appium cannot see the page. `E2E_SKIP_BUILD` and +`E2E_APP_PATH` (an `.apk` / simulator `.app`) work as on desktop. No config override is +passed there: both things it turns on belong to desktop-only plugins. + +## Environment variables + +| Variable | Purpose | +| ------------------- | ---------------------------------------------------------------------------------------- | +| `CN_API_KEY` | CrabNebula Cloud key. Required on macOS (and whenever `E2E_CN_WEBDRIVER=1`). | +| `E2E_SKIP_BUILD` | Skip the `tauri build` step and reuse the existing binary. | +| `E2E_APP_PATH` | Absolute path to a prebuilt app/binary to test (also implies skip-build). | +| `E2E_SKIP` | Comma-separated plugin names to skip, e.g. `E2E_SKIP=clipboard-manager,global-shortcut`. | +| `E2E_SKIP_WM` | Skip window-manager-dependent tests (the window-state size restore). | +| `E2E_SPEC_RETRIES` | Retry count for flaky spec files (default `0`). | +| `E2E_CN_WEBDRIVER` | Use the CrabNebula Webdriver on Linux/Windows too (instead of the native driver). | +| `E2E_NATIVE_DRIVER` | Path passed to `tauri-driver --native-driver` (e.g. a specific chromedriver). | +| `CARGO_TARGET_DIR` | Override the target dir the app binary is looked up in. | + +Mobile only: + +| Variable | Purpose | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| `E2E_ANDROID_TARGET` | Rust target for the APK (`aarch64`, `armv7`, `i686`, `x86_64`); default: the connected device's ABI. | +| `E2E_ANDROID_DEVICE` | `adb` serial of the device/emulator to use (`appium:udid`); default: the first connected one. | +| `E2E_ANDROID_AVD` | Name of an AVD for Appium to boot (`appium:avd`) instead of using an already-running emulator. | +| `E2E_CHROMEDRIVER` | chromedriver binary matching the device's WebView, instead of letting Appium download one. | +| `E2E_IOS_TARGET` | Rust target for the simulator app (`aarch64-sim` or `x86_64`); default: the host architecture. | +| `E2E_IOS_DEVICE` | Simulator UDID or name (as in `xcrun simctl list`); default: a booted iPhone, else the newest one. | +| `E2E_PLATFORM` | Set by the mobile configs for the spec workers (`android`/`ios`) — see `platform` in the helpers. | + +Appium's own log is written to `logs/wdio-appium.log` in this package. + +## Adding tests for a new plugin API + +1. **Add a spec.** Create `test/specs/.spec.ts` and use `describePlugin('', …)` + with the `tauri()` helper. It is picked up automatically by the `test/specs/**/*.spec.ts` + glob. Minimal example: + + ```ts + import { expect } from '@wdio/globals' + import { tauri, describePlugin } from '../helpers/index.js' + + describePlugin('os', () => { + it('reports the platform', async () => { + expect(await tauri((api) => api.os.platform())).toBe('linux') + }) + }) + ``` + + If the plugin is new to the example, register it in + [`examples/api/src-tauri/src/lib.rs`](../../examples/api/src-tauri/src/lib.rs), add it to + the example's `package.json`/`Cargo.toml`, add its `workspace:*` package to this package's + `devDependencies` and its namespace to the matching interface in + [`test/helpers/index.ts`](test/helpers/index.ts) — `CommonPluginApi`, `DesktopPluginApi` or + `MobilePluginApi` — plus the member list in `plugins.spec.ts`. + +2. **Grant permissions if needed.** If the API calls a command that the example does not + allow yet, add the permission to + [`examples/api/src-tauri/capabilities/base.json`](../../examples/api/src-tauri/capabilities/base.json) + (or `desktop.json`/`mobile.json` for platform-specific plugins). Each plugin documents its + permissions under `plugins//permissions/autogenerated/reference.md`. + +3. **Need a server?** Add a route to the [fixture server](test/helpers/server.ts) rather than + hitting the network; its URL is exported as `FIXTURE_SERVER_URL`. + +4. **Handle environment-sensitive cases.** Use `itWm` (instead of `it`) for assertions that + depend on a real window manager, and `eventually()` to poll for state that is applied + asynchronously. Branch on `platform` from the helpers (never `process.platform`, which is + the host running the emulator/simulator on mobile) for platform-specific behavior. + Files go under `scratchDir('')`, relative to `BaseDirectory.AppData`, which the + example's fs scope allows. + +5. **Gate what mobile does not have.** The same specs run on Android and iOS. Wrap tests of + desktop-only behavior — a command the mobile build does not expose, a mobile implementation + that answers "Unsupported on this platform", or a permission only the desktop capability + grants — in `itDesktop`, use `itOn('android', …)` / `itOn('ios', …)` for platform-specific + APIs, and pass `{ desktopOnly: true }` (or `{ mobileOnly: true }`) to `describePlugin` for + plugins the example does not register on the other side. Skipped tests show up as pending + rather than silently disappearing. + +### Rules for `tauri()` page functions + +The function you pass to `tauri()` runs **inside the webview**, serialized as a string: + +- It **cannot** close over anything from the spec module — pass every value it needs through + the trailing `tauri(fn, ...args)` arguments. +- It may only reference `api` (the `window.__TAURI__` object), those args, and browser + globals (`window`, `document`, `setTimeout`, `Promise`, …). +- Its return value must be JSON-serializable — return plain objects/primitives, not class + instances (call methods and return their results instead), and remember that `undefined` + values are dropped. +- Restore any app state you mutate (clipboard, registered shortcuts, window size, …); tests + within a spec file share the same app instance. +- For in-page waiting, wrap logic in a `Promise` with an explicit `setTimeout` rejection so a + failure surfaces as a message rather than an opaque driver timeout. + +Use `tauriError(fn, ...args)` to assert that a call rejects; it returns the rejection message. diff --git a/packages/api-e2e/package.json b/packages/api-e2e/package.json new file mode 100644 index 0000000..b8226b5 --- /dev/null +++ b/packages/api-e2e/package.json @@ -0,0 +1,62 @@ +{ + "name": "api-e2e", + "private": true, + "version": "0.0.0", + "description": "End-to-end test suite for the plugins' JavaScript APIs, driven against the examples/api app via WebdriverIO (tauri-driver on desktop, Appium on mobile).", + "license": "MIT OR Apache-2.0", + "type": "module", + "scripts": { + "e2e": "wdio run ./wdio.conf.ts", + "e2e:skip-build": "cross-env E2E_SKIP_BUILD=true wdio run ./wdio.conf.ts", + "e2e:android": "wdio run ./wdio.android.conf.ts", + "e2e:ios": "wdio run ./wdio.ios.conf.ts", + "ts:check": "tsc --noEmit" + }, + "devDependencies": { + "@crabnebula/tauri-driver": "^2.0.9", + "@crabnebula/test-runner-backend": "^0.3.0", + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-autostart": "workspace:*", + "@tauri-apps/plugin-barcode-scanner": "workspace:*", + "@tauri-apps/plugin-biometric": "workspace:*", + "@tauri-apps/plugin-cli": "workspace:*", + "@tauri-apps/plugin-clipboard-manager": "workspace:*", + "@tauri-apps/plugin-deep-link": "workspace:*", + "@tauri-apps/plugin-dialog": "workspace:*", + "@tauri-apps/plugin-fs": "workspace:*", + "@tauri-apps/plugin-geolocation": "workspace:*", + "@tauri-apps/plugin-global-shortcut": "workspace:*", + "@tauri-apps/plugin-haptics": "workspace:*", + "@tauri-apps/plugin-http": "workspace:*", + "@tauri-apps/plugin-log": "workspace:*", + "@tauri-apps/plugin-nfc": "workspace:*", + "@tauri-apps/plugin-notification": "workspace:*", + "@tauri-apps/plugin-opener": "workspace:*", + "@tauri-apps/plugin-os": "workspace:*", + "@tauri-apps/plugin-positioner": "workspace:*", + "@tauri-apps/plugin-process": "workspace:*", + "@tauri-apps/plugin-shell": "workspace:*", + "@tauri-apps/plugin-sql": "workspace:*", + "@tauri-apps/plugin-store": "workspace:*", + "@tauri-apps/plugin-stronghold": "workspace:*", + "@tauri-apps/plugin-updater": "workspace:*", + "@tauri-apps/plugin-upload": "workspace:*", + "@tauri-apps/plugin-websocket": "workspace:*", + "@tauri-apps/plugin-window-state": "workspace:*", + "@types/node": "^22.10.0", + "@types/ws": "^8.18.1", + "@wdio/appium-service": "^9.31.9", + "@wdio/cli": "^9.31.9", + "@wdio/globals": "^9.31.3", + "@wdio/local-runner": "^9.31.9", + "@wdio/mocha-framework": "^9.31.9", + "@wdio/spec-reporter": "^9.31.2", + "appium": "^3.7.0", + "appium-uiautomator2-driver": "^8.7.0", + "appium-xcuitest-driver": "^12.12.4", + "cross-env": "^10.1.0", + "typescript": "6.0.3", + "webdriverio": "^9.31.9", + "ws": "^8.18.0" + } +} diff --git a/packages/api-e2e/tauri.e2e.conf.json b/packages/api-e2e/tauri.e2e.conf.json new file mode 100644 index 0000000..278624b --- /dev/null +++ b/packages/api-e2e/tauri.e2e.conf.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../examples/api/node_modules/@tauri-apps/cli/config.schema.json", + "build": { + "features": ["automation"] + }, + "plugins": { + "updater": { + "endpoints": [ + "http://127.0.0.1:3004/updater/{{target}}/{{arch}}/{{current_version}}" + ] + } + } +} diff --git a/packages/api-e2e/test/helpers/index.ts b/packages/api-e2e/test/helpers/index.ts new file mode 100644 index 0000000..6da724c --- /dev/null +++ b/packages/api-e2e/test/helpers/index.ts @@ -0,0 +1,343 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { browser } from '@wdio/globals' +import type * as TauriApi from '@tauri-apps/api' +import type * as Autostart from '@tauri-apps/plugin-autostart' +import type * as BarcodeScanner from '@tauri-apps/plugin-barcode-scanner' +import type * as Biometric from '@tauri-apps/plugin-biometric' +import type * as Cli from '@tauri-apps/plugin-cli' +import type * as ClipboardManager from '@tauri-apps/plugin-clipboard-manager' +import type * as DeepLink from '@tauri-apps/plugin-deep-link' +import type * as Dialog from '@tauri-apps/plugin-dialog' +import type * as Fs from '@tauri-apps/plugin-fs' +import type * as Geolocation from '@tauri-apps/plugin-geolocation' +import type * as GlobalShortcut from '@tauri-apps/plugin-global-shortcut' +import type * as Haptics from '@tauri-apps/plugin-haptics' +import type * as Http from '@tauri-apps/plugin-http' +import type * as Log from '@tauri-apps/plugin-log' +import type * as Nfc from '@tauri-apps/plugin-nfc' +import type * as Notification from '@tauri-apps/plugin-notification' +import type * as Opener from '@tauri-apps/plugin-opener' +import type * as Os from '@tauri-apps/plugin-os' +import type * as Positioner from '@tauri-apps/plugin-positioner' +import type * as Process from '@tauri-apps/plugin-process' +import type * as Shell from '@tauri-apps/plugin-shell' +import type * as Sql from '@tauri-apps/plugin-sql' +import type * as Store from '@tauri-apps/plugin-store' +import type * as Stronghold from '@tauri-apps/plugin-stronghold' +import type * as Updater from '@tauri-apps/plugin-updater' +import type * as Upload from '@tauri-apps/plugin-upload' +import type * as WebSocket from '@tauri-apps/plugin-websocket' +import type * as WindowState from '@tauri-apps/plugin-window-state' + +/** + * The plugin APIs the example registers on every platform, keyed by the name + * each plugin's `api-iife.js` defines on `window.__TAURI__` (the package name + * without the `@tauri-apps/plugin-` prefix, camel-cased). `sql` and `websocket` + * only have a default export, so their global is that class itself. + */ +export interface CommonPluginApi { + clipboardManager: typeof ClipboardManager + deepLink: typeof DeepLink + dialog: typeof Dialog + fs: typeof Fs + http: typeof Http + log: typeof Log + notification: typeof Notification + opener: typeof Opener + os: typeof Os + process: typeof Process + shell: typeof Shell + sql: typeof Sql.default + store: typeof Store + stronghold: typeof Stronghold + upload: typeof Upload + websocket: typeof WebSocket.default +} + +/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */ +export interface DesktopPluginApi { + autostart: typeof Autostart + cli: typeof Cli + globalShortcut: typeof GlobalShortcut + positioner: typeof Positioner + updater: typeof Updater + windowState: typeof WindowState +} + +/** The plugin APIs the example only registers on mobile (`#[cfg(mobile)]`). */ +export interface MobilePluginApi { + barcodeScanner: typeof BarcodeScanner + biometric: typeof Biometric + geolocation: typeof Geolocation + haptics: typeof Haptics + nfc: typeof Nfc +} + +/** + * Every plugin API the example can register. Only the platform-appropriate + * half is actually on `window.__TAURI__` at runtime — see `describePlugin`'s + * `desktopOnly`/`mobileOnly` options and `plugins.spec.ts`. + */ +export type PluginApi = CommonPluginApi & DesktopPluginApi & MobilePluginApi + +/** The `@tauri-apps/api` surface plus every plugin, as exposed on `window.__TAURI__`. */ +export type Api = typeof TauriApi & PluginApi + +/** OS the app under test runs on. */ +export type Platform = NodeJS.Platform | 'android' | 'ios' + +/** + * The platform of the app under test. The desktop suite drives an app on the + * host, so it is `process.platform`; the mobile configs (`wdio.android.conf.ts`, + * `wdio.ios.conf.ts`) drive an emulator/simulator and set `E2E_PLATFORM` for + * the spec workers instead. + */ +export const platform: Platform = + (process.env.E2E_PLATFORM as Platform | undefined) ?? process.platform + +export const isMobile = platform === 'android' || platform === 'ios' + +type PageOutcome = + | { ok: true; value: T } + | { ok: false; error: string; stack?: string } + +/** Thrown when the function passed to {@link tauri} rejects inside the webview. */ +export class TauriPageError extends Error { + pageStack?: string + constructor(message: string, pageStack?: string) { + super(message) + this.name = 'TauriPageError' + this.pageStack = pageStack + } +} + +/** + * Runs `fn` inside the app's webview with `window.__TAURI__` as its first argument + * and resolves with its (JSON-serializable) return value. + * + * `fn` is serialized with `Function.prototype.toString`, so it **cannot close over + * anything in the spec module** — every value it needs must be passed through `args`, + * and it may only reference `api`, those args, and browser globals (`window`, + * `document`, `setTimeout`, `Promise`, ...). + * + * @example + * const platform = await tauri((api) => api.os.platform()) + * const sum = await tauri((api, a, b) => a + b, 2, 3) + */ +export async function tauri( + fn: (api: Api, ...args: A) => R, + ...args: A +): Promise> { + // A string body (rather than passing `fn` directly) keeps this working across + // both the classic and bidi WebDriver protocols and avoids any in-page eval of + // our own — the driver injects this script itself, which is exempt from the + // app's CSP. `executeAsync` is used because promise support in `execute` is not + // uniform across the platform drivers tauri-driver and Appium proxy to. + // + // The outcome crosses the driver as a JSON string rather than an object so no + // driver gets to interpret its shape: the Selenium atoms that Appium runs + // scripts through on iOS turn any object with a numeric `length` property + // into an array. + const script = ` + var done = arguments[arguments.length - 1]; + var args = Array.prototype.slice.call(arguments, 0, arguments.length - 1); + var fn = (${fn.toString()}); + Promise.resolve() + .then(function () { return fn.apply(null, [window.__TAURI__].concat(args)); }) + .then( + function (value) { return { ok: true, value: value === undefined ? null : value }; }, + function (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }; + } + ) + .then(function (outcome) { + try { + done(JSON.stringify(outcome)); + } catch (error) { + done(JSON.stringify({ ok: false, error: 'result is not JSON-serializable: ' + error })); + } + }); + ` + const raw: unknown = await browser.executeAsync(script, ...args) + const outcome = ( + typeof raw === 'string' ? JSON.parse(raw) : raw + ) as PageOutcome> | null + if (!outcome || typeof outcome !== 'object' || !('ok' in outcome)) { + throw new Error( + `tauri() bridge returned an unexpected value: ${JSON.stringify(outcome)}` + ) + } + if (!outcome.ok) { + throw new TauriPageError(outcome.error, outcome.stack) + } + return outcome.value +} + +/** + * Asserts that the page-side call rejects and returns the rejection message, + * so specs can assert on it. Throws if the call unexpectedly resolves. + */ +export async function tauriError( + fn: (api: Api, ...args: A) => unknown, + ...args: A +): Promise { + try { + await tauri(fn, ...args) + } catch (error) { + if (error instanceof TauriPageError) { + return error.message + } + // Some platform drivers (notably the Linux WebKitWebDriver) surface a + // page-side `invoke` rejection as a WebDriver-level error on the + // `execute/async` command instead of letting the in-page bridge report it + // as an `{ ok: false }` outcome. Fall back to that error's message so the + // backend rejection is still assertable. This is safe for error-path specs: + // they match the message against an expected pattern, so a genuine driver + // failure (whose message won't match) still fails the test. + if (error instanceof Error) { + return error.message + } + throw error + } + throw new Error('expected the API call to reject, but it resolved') +} + +/** + * Polls `check` until it returns without throwing or `timeout` elapses. + * Use for state that is applied asynchronously (window manager, file watcher, ...). + */ +export async function eventually( + check: () => T | Promise, + { + timeout = 10_000, + interval = 250 + }: { timeout?: number; interval?: number } = {} +): Promise { + const deadline = Date.now() + timeout + let lastError: unknown + for (;;) { + try { + return await check() + } catch (error) { + lastError = error + } + if (Date.now() > deadline) { + throw lastError instanceof Error + ? lastError + : new Error(String(lastError)) + } + await new Promise((resolve) => setTimeout(resolve, interval)) + } +} + +const skippedModules = (process.env.E2E_SKIP ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + +export interface DescribePluginOptions { + /** + * The example only registers the plugin on desktop (`autostart`, `cli`, + * `global-shortcut`, `positioner`, `updater`, `window-state`), so the whole + * suite is skipped on mobile. + */ + desktopOnly?: boolean + /** + * The example only registers the plugin on mobile (`barcode-scanner`, + * `biometric`, `geolocation`, `haptics`, `nfc`), so the whole suite is + * skipped on desktop. + */ + mobileOnly?: boolean +} + +/** + * `describe` wrapper keyed by plugin name (the `@tauri-apps/plugin-*` suffix). + * Any plugin listed in the comma-separated `E2E_SKIP` env var + * (e.g. `E2E_SKIP=clipboard-manager,global-shortcut`) is skipped. + */ +export function describePlugin(plugin: string, fn: () => void): void +export function describePlugin( + plugin: string, + options: DescribePluginOptions, + fn: () => void +): void +export function describePlugin( + plugin: string, + optionsOrFn: DescribePluginOptions | (() => void), + maybeFn?: () => void +): void { + const [options, fn] = + typeof optionsOrFn === 'function' + ? [{} as DescribePluginOptions, optionsOrFn] + : [optionsOrFn, maybeFn!] + const title = `@tauri-apps/plugin-${plugin}` + if ( + skippedModules.includes(plugin) + || (options.desktopOnly && isMobile) + || (options.mobileOnly && !isMobile) + ) { + describe.skip(title, fn) + } else { + describe(title, fn) + } +} + +/** + * `it` restricted to the given platform(s); skipped (as pending) elsewhere. + * Use for behavior that only exists on one OS. + */ +export function itOn( + platforms: Platform | Platform[], + title: string, + fn: () => void | Promise +): void { + const list = Array.isArray(platforms) ? platforms : [platforms] + if (list.includes(platform)) { + it(title, fn) + } else { + it.skip(title, fn) + } +} + +/** + * `it` for desktop-only behavior of a plugin that *is* registered on mobile: + * a command the mobile build does not expose (`#[cfg(desktop)]`), a mobile + * implementation that answers "Unsupported on this platform", or a permission + * the example only grants in its desktop capability. + */ +export function itDesktop(title: string, fn: () => void | Promise): void { + if (isMobile) { + it.skip(title, fn) + } else { + it(title, fn) + } +} + +/** + * `it` for assertions that depend on a real window manager (window size and + * position restore, ...). Skipped entirely when `E2E_SKIP_WM` is set (e.g. + * bare headless CI), and on mobile, which has no window manager. + */ +export function itWm(title: string, fn: () => void | Promise): void { + if (process.env.E2E_SKIP_WM || isMobile) { + it.skip(title, fn) + } else { + it(title, fn) + } +} + +/** + * A scratch directory the fs-backed specs may freely write to. It is relative + * to `BaseDirectory.AppData` (`$APPDATA`), which the example's fs scope allows + * recursively; `name` keeps each spec file's files apart. + */ +export function scratchDir(name: string): string { + return `e2e/${name}` +} diff --git a/packages/api-e2e/test/helpers/server.ts b/packages/api-e2e/test/helpers/server.ts new file mode 100644 index 0000000..204f741 --- /dev/null +++ b/packages/api-e2e/test/helpers/server.ts @@ -0,0 +1,155 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import http from 'node:http' +import { WebSocketServer } from 'ws' + +/** + * Where the fixture server listens. The port is fixed because the updater + * endpoint is baked into the app at build time (see `tauri.e2e.conf.json`). + */ +export const FIXTURE_SERVER_PORT = 3004 +export const FIXTURE_SERVER_URL = `http://127.0.0.1:${FIXTURE_SERVER_PORT}` + +/** Version the `/updater` endpoint advertises by default. */ +export const UPDATER_FIXTURE_VERSION = '2.1.0' +export const UPDATER_FIXTURE_NOTES = 'Test update from the e2e fixture server' +/** `{{target}}` values with special behavior on the `/updater` endpoint. */ +export const UPDATER_TARGET_NO_UPDATE = 'e2e-no-update' +export const UPDATER_TARGET_OLDER = 'e2e-older' +export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0' + +/** Body served by `GET /download`. */ +export const DOWNLOAD_FIXTURE_BODY = + 'hello from the plugins e2e fixture server\n'.repeat(64) + +/** WebSocket endpoint of the fixture server (the `ws://` twin of {@link FIXTURE_SERVER_URL}). */ +export const WEBSOCKET_FIXTURE_URL = `ws://127.0.0.1:${FIXTURE_SERVER_PORT}/ws` +/** Text message that makes the `/ws` endpoint close the connection with {@link WEBSOCKET_CLOSE_CODE}. */ +export const WEBSOCKET_CLOSE_REQUEST = 'close-me' +export const WEBSOCKET_CLOSE_CODE = 4000 +export const WEBSOCKET_CLOSE_REASON = 'closed by the fixture server' + +export interface FixtureServer { + close(): void +} + +/** + * A tiny HTTP server the network-facing specs (updater, upload) talk to: + * + * - `GET /updater/{{target}}/{{arch}}/{{current_version}}` — an updater + * manifest in the dynamic format. Advertises {@link UPDATER_FIXTURE_VERSION}, + * or {@link UPDATER_FIXTURE_OLDER_VERSION} when the target is + * {@link UPDATER_TARGET_OLDER}, and replies `204 No Content` when it is + * {@link UPDATER_TARGET_NO_UPDATE}. + * - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`. + * - `* /echo` — a JSON description of the request (`method`, `url`, `headers` + * and the utf-8 `body`). + * - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent + * back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the + * connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server + * first sends the upgrade request's headers as a JSON text message. + */ +export function startFixtureServer(): Promise { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + const body = Buffer.concat(chunks) + const url = new URL(req.url ?? '/', FIXTURE_SERVER_URL) + const [, route, ...rest] = url.pathname.split('/') + + if (route === 'updater' && req.method === 'GET') { + const [target] = rest + if (target === UPDATER_TARGET_NO_UPDATE) { + res.writeHead(204).end() + return + } + json(res, { + version: + target === UPDATER_TARGET_OLDER + ? UPDATER_FIXTURE_OLDER_VERSION + : UPDATER_FIXTURE_VERSION, + notes: UPDATER_FIXTURE_NOTES, + pub_date: '2026-03-01T14:04:20Z', + url: `${FIXTURE_SERVER_URL}/download`, + signature: '' + }) + return + } + + if (route === 'download' && req.method === 'GET') { + res + .writeHead(200, { + 'content-type': 'text/plain', + 'content-length': Buffer.byteLength(DOWNLOAD_FIXTURE_BODY) + }) + .end(DOWNLOAD_FIXTURE_BODY) + return + } + + if (route === 'echo') { + json(res, { + method: req.method, + url: req.url, + headers: req.headers, + body: body.toString('utf8') + }) + return + } + + res.writeHead(404).end() + }) + }) + + const wss = new WebSocketServer({ noServer: true }) + server.on('upgrade', (req, socket, head) => { + const { pathname } = new URL(req.url ?? '/', FIXTURE_SERVER_URL) + if (pathname !== '/ws' && pathname !== '/ws/headers') { + socket.destroy() + return + } + wss.handleUpgrade(req, socket, head, (ws) => { + if (pathname === '/ws/headers') { + ws.send(JSON.stringify(req.headers)) + } + ws.on('message', (data, isBinary) => { + if ( + !isBinary + && Buffer.isBuffer(data) + && data.toString('utf8') === WEBSOCKET_CLOSE_REQUEST + ) { + ws.close(WEBSOCKET_CLOSE_CODE, WEBSOCKET_CLOSE_REASON) + } else { + ws.send(data, { binary: isBinary }) + } + }) + }) + }) + + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => { + server.off('error', reject) + resolve({ + close: () => { + for (const client of wss.clients) client.terminate() + wss.close() + server.closeAllConnections() + server.close() + } + }) + }) + }) +} + +function json(res: http.ServerResponse, value: unknown) { + const payload = JSON.stringify(value) + res + .writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload) + }) + .end(payload) +} diff --git a/packages/api-e2e/test/specs/app.spec.ts b/packages/api-e2e/test/specs/app.spec.ts new file mode 100644 index 0000000..a76604e --- /dev/null +++ b/packages/api-e2e/test/specs/app.spec.ts @@ -0,0 +1,71 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri } from '../helpers/index.js' + +// The example app's own commands and events, which its "Communication" view +// drives; they double as a check that the app under test is the right one. + +describe('examples/api', () => { + it('is built from the example config', async () => { + const info = await tauri(async (api) => ({ + name: await api.app.getName(), + version: await api.app.getVersion(), + identifier: await api.app.getIdentifier(), + label: api.window.getCurrentWindow().label + })) + expect(info).toEqual({ + name: 'Tauri API', + version: '2.0.0', + identifier: 'com.tauri.api', + label: 'main' + }) + }) + + it('perform_request returns the backend response', async () => { + const response = await tauri((api) => + api.core.invoke('perform_request', { + endpoint: 'dummy endpoint arg', + body: { id: 5, name: 'test' } + }) + ) + expect(response).toBe('message response') + }) + + it('log_operation accepts an optional payload', async () => { + await tauri(async (api) => { + await api.core.invoke('log_operation', { event: 'tauri-click' }) + await api.core.invoke('log_operation', { + event: 'tauri-click', + payload: 'from e2e' + }) + return null + }) + }) + + it('js-event is answered with rust-event', async () => { + const reply = await tauri( + (api) => + new Promise<{ data: string }>((resolve, reject) => { + const webview = api.webview.getCurrentWebview() + webview + .listen<{ data: string }>('rust-event', (event) => + resolve(event.payload) + ) + .then((unlisten) => { + webview + .emit('js-event', 'this is the payload string') + .catch(reject) + setTimeout(() => { + unlisten() + reject(new Error('no rust-event reply received')) + }, 5000) + }) + .catch(reject) + }) + ) + expect(reply).toEqual({ data: 'something else' }) + }) +}) diff --git a/packages/api-e2e/test/specs/autostart.spec.ts b/packages/api-e2e/test/specs/autostart.spec.ts new file mode 100644 index 0000000..b45a0af --- /dev/null +++ b/packages/api-e2e/test/specs/autostart.spec.ts @@ -0,0 +1,58 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, describePlugin } from '../helpers/index.js' + +// Enabling autostart registers the app with the host for real (a Launch Agent +// on macOS, an XDG autostart entry on Linux, a `Run` registry value on +// Windows), so the spec puts back whatever state it found. + +describePlugin('autostart', { desktopOnly: true }, () => { + let initiallyEnabled = false + + before(async () => { + initiallyEnabled = await tauri((api) => api.autostart.isEnabled()) + }) + + after(async () => { + await tauri( + (api, enabled) => + enabled ? api.autostart.enable() : api.autostart.disable(), + initiallyEnabled + ) + }) + + it('enable and disable toggle isEnabled', async () => { + const states = await tauri(async (api) => { + await api.autostart.enable() + const enabled = await api.autostart.isEnabled() + await api.autostart.disable() + return { enabled, disabled: await api.autostart.isEnabled() } + }) + expect(states).toEqual({ enabled: true, disabled: false }) + }) + + it('enabling twice keeps it enabled', async () => { + const enabled = await tauri(async (api) => { + await api.autostart.enable() + await api.autostart.enable() + const enabled = await api.autostart.isEnabled() + await api.autostart.disable() + return enabled + }) + expect(enabled).toBe(true) + }) + + it('disable resolves when autostart is already disabled', async () => { + // Windows used to fail here, having no `Run` registry value to delete + const disabled = await tauri(async (api) => { + await api.autostart.enable() + await api.autostart.disable() + await api.autostart.disable() + return !(await api.autostart.isEnabled()) + }) + expect(disabled).toBe(true) + }) +}) diff --git a/packages/api-e2e/test/specs/cli.spec.ts b/packages/api-e2e/test/specs/cli.spec.ts new file mode 100644 index 0000000..e817bcc --- /dev/null +++ b/packages/api-e2e/test/specs/cli.spec.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, describePlugin } from '../helpers/index.js' + +// The driver launches the app without arguments, so the matches reflect the +// CLI definition in the example's `tauri.conf.json` with nothing set. The +// example only registers the plugin on desktop, so the suite is skipped on +// mobile (which has no command line to begin with). + +describePlugin('cli', { desktopOnly: true }, () => { + it('getMatches reports every defined argument as unset', async () => { + const matches = await tauri((api) => api.cli.getMatches()) + expect(Object.keys(matches.args).sort()).toEqual([ + 'config', + 'theme', + 'verbose' + ]) + // flags resolve to `false`, arguments taking a value to `null` + expect(matches.args.verbose).toEqual({ value: false, occurrences: 0 }) + expect(matches.args.config).toEqual({ value: null, occurrences: 0 }) + expect(matches.args.theme).toEqual({ value: null, occurrences: 0 }) + }) + + it('getMatches reports no subcommand', async () => { + const subcommand = await tauri( + async (api) => (await api.cli.getMatches()).subcommand + ) + expect(subcommand).toBeNull() + }) +}) diff --git a/packages/api-e2e/test/specs/clipboard-manager.spec.ts b/packages/api-e2e/test/specs/clipboard-manager.spec.ts new file mode 100644 index 0000000..f5bf0e4 --- /dev/null +++ b/packages/api-e2e/test/specs/clipboard-manager.spec.ts @@ -0,0 +1,158 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + tauriError, + describePlugin, + itDesktop, + isMobile +} from '../helpers/index.js' + +// The mobile implementation only carries plain text: `write_html`, `write_image` +// and `read_image` answer "Unsupported on this platform" there. +describePlugin('clipboard-manager', () => { + it('writeText and readText round-trip', async () => { + const text = 'clipboard text from e2e — ✓' + expect( + await tauri(async (api, text) => { + await api.clipboardManager.writeText(text) + return api.clipboardManager.readText() + }, text) + ).toBe(text) + }) + + it('writeText replaces the previous contents', async () => { + expect( + await tauri(async (api) => { + await api.clipboardManager.writeText('first') + await api.clipboardManager.writeText('second') + return api.clipboardManager.readText() + }) + ).toBe('second') + }) + + itDesktop('writeHtml exposes the alt text as plain text', async () => { + expect( + await tauri(async (api) => { + await api.clipboardManager.writeHtml( + 'bold from e2e', + 'bold from e2e (alt)' + ) + return api.clipboardManager.readText() + }) + ).toBe('bold from e2e (alt)') + }) + + itDesktop('writeImage and readImage round-trip pixels', async () => { + // a 2x2 PNG: red, green / blue, white + const png = [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, + 0, 0, 0, 2, 8, 6, 0, 0, 0, 114, 182, 13, 36, 0, 0, 0, 18, 73, 68, 65, 84, + 120, 218, 99, 248, 207, 192, 240, 31, 12, 129, 52, 24, 0, 0, 73, 200, 9, + 247, 3, 217, 100, 241, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130 + ] + const rgba = [ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255 + ] + const result = await tauri(async (api, png) => { + // encoded bytes are decoded by the plugin before hitting the clipboard + await api.clipboardManager.writeImage(new Uint8Array(png)) + const read = await api.clipboardManager.readImage() + const size = await read.size() + const bytes = Array.from(await read.rgba()) + await read.close() + return { size, bytes } + }, png) + expect(result.size).toEqual({ width: 2, height: 2 }) + expect(result.bytes).toEqual(rgba) + }) + + itDesktop( + 'writeImage accepts an Image built with the core image API', + async () => { + // `window.__TAURI__.image.Image` and the class the plugin's global script + // sees must be the same one for `transformImage`'s `instanceof` to hold. + const rgba = [ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255 + ] + const result = await tauri(async (api, rgba) => { + const image = await api.image.Image.new(rgba, 2, 2) + await api.clipboardManager.writeImage(image) + const read = await api.clipboardManager.readImage() + const size = await read.size() + const bytes = Array.from(await read.rgba()) + await image.close() + await read.close() + return { size, bytes } + }, rgba) + expect(result.size).toEqual({ width: 2, height: 2 }) + expect(result.bytes).toEqual(rgba) + } + ) + + itDesktop( + 'an Image read from the clipboard can be written back', + async () => { + const result = await tauri(async (api) => { + const read = await api.clipboardManager.readImage() + await api.clipboardManager.writeText('replaced by text') + // `Image` instances are passed by resource id + await api.clipboardManager.writeImage(read) + const again = await api.clipboardManager.readImage() + const size = await again.size() + await read.close() + await again.close() + return size + }) + expect(result).toEqual({ width: 2, height: 2 }) + } + ) + + itDesktop('writeImage accepts a 1x1 image', async () => { + // a 1x1 transparent PNG + const png = [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, + 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, + 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, + 69, 78, 68, 174, 66, 96, 130 + ] + const size = await tauri(async (api, png) => { + await api.clipboardManager.writeImage(new Uint8Array(png)) + const read = await api.clipboardManager.readImage() + const size = await read.size() + await read.close() + return size + }, png) + expect(size).toEqual({ width: 1, height: 1 }) + }) + + it('readImage rejects when the clipboard holds text', async () => { + const message = await tauriError(async (api) => { + await api.clipboardManager.writeText('not an image') + await api.clipboardManager.readImage() + }) + expect(message).toMatch( + isMobile + ? /Unsupported on this platform/ + : /not available in the requested format/ + ) + }) + + it('clear empties the clipboard', async () => { + // reading an empty clipboard rejects on some platforms and yields an + // empty string on others + const text = await tauri(async (api) => { + await api.clipboardManager.writeText('to be cleared') + await api.clipboardManager.clear() + try { + return await api.clipboardManager.readText() + } catch { + return '' + } + }) + expect(text).toBe('') + }) +}) diff --git a/packages/api-e2e/test/specs/deep-link.spec.ts b/packages/api-e2e/test/specs/deep-link.spec.ts new file mode 100644 index 0000000..1deb711 --- /dev/null +++ b/packages/api-e2e/test/specs/deep-link.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, tauriError, describePlugin, itOn } from '../helpers/index.js' + +// The suite launches the app without a URL, so there is no current deep link, +// and it cannot open one through the OS either. `onOpenUrl` is exercised by +// emitting the event the plugin itself emits when the app is opened with one. +// +// Registering a scheme at runtime is only implemented on Linux (a `.desktop` +// handler plus `xdg-mime`) and Windows (the registry); macOS and mobile read +// the schemes from the bundle and report the runtime APIs as unsupported. + +/** A scheme no real app handles, so registering it cannot clobber anything. */ +const scheme = 'tauri-plugins-e2e' + +describePlugin('deep-link', () => { + it('getCurrent is null when the app was not opened with a URL', async () => { + expect(await tauri((api) => api.deepLink.getCurrent())).toBeNull() + }) + + it('onOpenUrl delivers the opened URLs until unlistened', async () => { + const received = await tauri( + async (api, urls) => { + const received: string[][] = [] + const unlisten = await api.deepLink.onOpenUrl((urls) => + received.push(urls) + ) + await api.event.emit('deep-link://new-url', urls) + await new Promise((resolve) => setTimeout(resolve, 500)) + unlisten() + await api.event.emit('deep-link://new-url', ['ignored://']) + await new Promise((resolve) => setTimeout(resolve, 500)) + return received + }, + [`${scheme}://open/path?query=1`] + ) + expect(received).toEqual([[`${scheme}://open/path?query=1`]]) + }) + + itOn( + ['linux', 'win32'], + 'register and unregister toggle the scheme handler', + async () => { + const result = await tauri(async (api, scheme) => { + await api.deepLink.register(scheme) + const registered = await api.deepLink.isRegistered(scheme) + await api.deepLink.unregister(scheme) + return { + registered, + afterUnregister: await api.deepLink.isRegistered(scheme) + } + }, scheme) + expect(result).toEqual({ registered: true, afterUnregister: false }) + } + ) + + itOn( + ['linux', 'win32'], + 'isRegistered is false for an unknown scheme', + async () => { + expect( + await tauri( + (api, scheme) => api.deepLink.isRegistered(scheme), + `${scheme}-unknown` + ) + ).toBe(false) + } + ) + + itOn( + ['darwin', 'android', 'ios'], + 'runtime registration is unsupported', + async () => { + for (const command of [ + 'register', + 'unregister', + 'isRegistered' + ] as const) { + const error = await tauriError( + // eslint-disable-next-line security/detect-object-injection + (api, command, scheme) => api.deepLink[command](scheme), + command, + scheme + ) + expect(error).toMatch(/unsupported platform/i) + } + } + ) +}) diff --git a/packages/api-e2e/test/specs/fs.spec.ts b/packages/api-e2e/test/specs/fs.spec.ts new file mode 100644 index 0000000..d590e03 --- /dev/null +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -0,0 +1,381 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + tauriError, + describePlugin, + itDesktop, + scratchDir +} from '../helpers/index.js' + +// Every path below is relative to `BaseDirectory.AppData`, which the example's +// fs scope allows recursively (`fs:scope-appdata-recursive`). +const dir = scratchDir('fs') + +describePlugin('fs', () => { + before(async () => { + await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + if (await api.fs.exists(dir, { baseDir })) { + await api.fs.remove(dir, { baseDir, recursive: true }) + } + await api.fs.mkdir(dir, { baseDir, recursive: true }) + }, dir) + }) + + after(async () => { + await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + if (await api.fs.exists(dir, { baseDir })) { + await api.fs.remove(dir, { baseDir, recursive: true }) + } + }, dir) + }) + + it('mkdir and exists report the scratch directory', async () => { + const result = await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + return { + dir: await api.fs.exists(dir, { baseDir }), + missing: await api.fs.exists(`${dir}/does-not-exist`, { baseDir }) + } + }, dir) + expect(result).toEqual({ dir: true, missing: false }) + }) + + it('writeTextFile and readTextFile round-trip utf-8 text', async () => { + const text = 'Hello from the e2e suite — olá, 世界! 🎉\nsecond line\n' + const read = await tauri( + async (api, path, text) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, text, { baseDir }) + return api.fs.readTextFile(path, { baseDir }) + }, + `${dir}/text.txt`, + text + ) + expect(read).toBe(text) + }) + + it('writeTextFile appends when asked to', async () => { + const read = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, 'first', { baseDir }) + await api.fs.writeTextFile(path, ' second', { baseDir, append: true }) + return api.fs.readTextFile(path, { baseDir }) + }, `${dir}/append.txt`) + expect(read).toBe('first second') + }) + + it('writeFile and readFile round-trip binary data', async () => { + const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255] + const read = await tauri( + async (api, path, bytes) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeFile(path, new Uint8Array(bytes), { baseDir }) + return Array.from(await api.fs.readFile(path, { baseDir })) + }, + `${dir}/binary.bin`, + bytes + ) + expect(read).toEqual(bytes) + }) + + it('stat, lstat and size describe files and directories', async () => { + const result = await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + const file = `${dir}/stat.txt` + await api.fs.writeTextFile(file, '0123456789', { baseDir }) + const fileStat = await api.fs.stat(file, { baseDir }) + const fileLstat = await api.fs.lstat(file, { baseDir }) + const dirStat = await api.fs.stat(dir, { baseDir }) + return { + file: { + isFile: fileStat.isFile, + isDirectory: fileStat.isDirectory, + isSymlink: fileStat.isSymlink, + size: fileStat.size, + hasMtime: fileStat.mtime instanceof Date + }, + lstatSize: fileLstat.size, + // `size` only takes absolute paths + size: await api.fs.size( + await api.path.join(await api.path.appDataDir(), file) + ), + dir: { isFile: dirStat.isFile, isDirectory: dirStat.isDirectory } + } + }, dir) + expect(result.file).toEqual({ + isFile: true, + isDirectory: false, + isSymlink: false, + size: 10, + hasMtime: true + }) + expect(result.lstatSize).toBe(10) + expect(result.size).toBe(10) + expect(result.dir).toEqual({ isFile: false, isDirectory: true }) + }) + + it('copyFile, rename and readDir', async () => { + const result = await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + const sub = `${dir}/tree` + await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true }) + await api.fs.writeTextFile(`${sub}/a.txt`, 'a', { baseDir }) + await api.fs.copyFile(`${sub}/a.txt`, `${sub}/b.txt`, { + fromPathBaseDir: baseDir, + toPathBaseDir: baseDir + }) + await api.fs.rename(`${sub}/b.txt`, `${sub}/c.txt`, { + oldPathBaseDir: baseDir, + newPathBaseDir: baseDir + }) + const entries = await api.fs.readDir(sub, { baseDir }) + return { + entries: entries + .map((e) => ({ + name: e.name, + isFile: e.isFile, + isDirectory: e.isDirectory + })) + .sort((x, y) => x.name.localeCompare(y.name)), + copied: await api.fs.readTextFile(`${sub}/c.txt`, { baseDir }), + renamedAway: await api.fs.exists(`${sub}/b.txt`, { baseDir }) + } + }, dir) + expect(result.entries).toEqual([ + { name: 'a.txt', isFile: true, isDirectory: false }, + { name: 'c.txt', isFile: true, isDirectory: false }, + { name: 'nested', isFile: false, isDirectory: true } + ]) + expect(result.copied).toBe('a') + expect(result.renamedAway).toBe(false) + }) + + it('remove deletes files and (recursively) directories', async () => { + const result = await tauri(async (api, dir) => { + const baseDir = api.fs.BaseDirectory.AppData + const sub = `${dir}/to-remove` + await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true }) + await api.fs.writeTextFile(`${sub}/nested/file.txt`, 'x', { baseDir }) + await api.fs.remove(`${sub}/nested/file.txt`, { baseDir }) + const fileGone = !(await api.fs.exists(`${sub}/nested/file.txt`, { + baseDir + })) + await api.fs.writeTextFile(`${sub}/nested/other.txt`, 'x', { baseDir }) + let nonRecursiveError: string | null = null + try { + await api.fs.remove(sub, { baseDir }) + } catch (error) { + nonRecursiveError = String(error) + } + await api.fs.remove(sub, { baseDir, recursive: true }) + return { + fileGone, + nonRecursiveError, + dirGone: !(await api.fs.exists(sub, { baseDir })) + } + }, dir) + expect(result.fileGone).toBe(true) + // a non-empty directory cannot be removed without `recursive` + expect(result.nonRecursiveError).not.toBeNull() + expect(result.dirGone).toBe(true) + }) + + it('truncate shortens a file', async () => { + const result = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, '0123456789', { baseDir }) + await api.fs.truncate(path, 4, { baseDir }) + const shortened = await api.fs.readTextFile(path, { baseDir }) + await api.fs.truncate(path, undefined, { baseDir }) + return { + shortened, + emptied: await api.fs.readTextFile(path, { baseDir }) + } + }, `${dir}/truncate.txt`) + expect(result).toEqual({ shortened: '0123', emptied: '' }) + }) + + it('readTextFileLines iterates a file line by line', async () => { + const lines = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, 'one\ntwo\r\nthree', { baseDir }) + const result: string[] = [] + for await (const line of await api.fs.readTextFileLines(path, { + baseDir + })) { + result.push(line) + } + return result + }, `${dir}/lines.txt`) + expect(lines).toEqual(['one', 'two', 'three']) + }) + + it('FileHandle supports write, seek, read, stat and truncate', async () => { + const result = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + const encoder = new TextEncoder() + const decoder = new TextDecoder() + + const created = await api.fs.create(path, { baseDir }) + const written = await created.write(encoder.encode('hello world')) + const statAfterWrite = await created.stat() + await created.close() + + const file = await api.fs.open(path, { baseDir, read: true, write: true }) + // seek past "hello " and read the rest + const position = await file.seek(6, api.fs.SeekMode.Start) + const buffer = new Uint8Array(32) + const read = await file.read(buffer) + const rest = decoder.decode(buffer.subarray(0, read ?? 0)) + // at EOF, read reports null + const atEof = await file.read(new Uint8Array(8)) + // relative and end-relative seeks + const fromEnd = await file.seek(-5, api.fs.SeekMode.End) + const relative = await file.seek(-1, api.fs.SeekMode.Current) + await file.truncate(5) + const statAfterTruncate = await file.stat() + await file.close() + + return { + written, + sizeAfterWrite: statAfterWrite.size, + position, + read, + rest, + atEof, + fromEnd, + relative, + sizeAfterTruncate: statAfterTruncate.size, + contents: await api.fs.readTextFile(path, { baseDir }) + } + }, `${dir}/handle.txt`) + expect(result).toEqual({ + written: 11, + sizeAfterWrite: 11, + position: 6, + read: 5, + rest: 'world', + atEof: null, + fromEnd: 6, + relative: 5, + sizeAfterTruncate: 5, + contents: 'hello' + }) + }) + + it('a closed FileHandle cannot be used again', async () => { + const message = await tauriError(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + const file = await api.fs.create(path, { baseDir }) + await file.close() + await file.stat() + }, `${dir}/closed.txt`) + expect(message).toMatch(/resource id \d+ is invalid/) + }) + + it('rejects paths outside the configured scope', async () => { + // `$HOME` itself is not in the example's fs scope (only the app dirs, + // `$DOWNLOAD` and `$RESOURCE` are). + const message = await tauriError(async (api) => + api.fs.readTextFile('e2e-forbidden.txt', { + baseDir: api.fs.BaseDirectory.Home + }) + ) + expect(message).toMatch(/forbidden path/) + }) + + it('rejects paths escaping the scope through `..`', async () => { + const message = await tauriError(async (api) => + api.fs.writeTextFile('../../e2e-escape.txt', 'nope', { + baseDir: api.fs.BaseDirectory.AppData + }) + ) + expect(message).toMatch(/cannot traverse directory|forbidden path/) + }) + + // The watch specs are desktop-only: `fs:allow-watch` is granted in the + // example's desktop capability only. + itDesktop( + 'watchImmediate reports changes in a watched directory', + async () => { + const result = await tauri(async (api, watched) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.mkdir(watched, { baseDir, recursive: true }) + const events: { kind: string; paths: string[] }[] = [] + const unwatch = await api.fs.watchImmediate( + watched, + (event) => { + events.push({ + kind: + typeof event.type === 'string' + ? event.type + : Object.keys(event.type)[0], + paths: event.paths + }) + }, + { baseDir, recursive: true } + ) + await api.fs.writeTextFile(`${watched}/touched.txt`, 'watched', { + baseDir + }) + // give the notifier a moment to deliver + const deadline = Date.now() + 10_000 + while ( + !events.some((e) => e.paths.some((p) => p.endsWith('touched.txt'))) + ) { + if (Date.now() > deadline) { + throw new Error( + `no watch event for touched.txt, got ${JSON.stringify(events)}` + ) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + unwatch() + return events + }, `${dir}/watched`) + expect( + result.some((e) => e.paths.some((p) => p.endsWith('touched.txt'))) + ).toBe(true) + } + ) + + itDesktop('watch debounces and unwatch stops delivery', async () => { + const result = await tauri(async (api, watched) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.mkdir(watched, { baseDir, recursive: true }) + let count = 0 + const unwatch = await api.fs.watch( + watched, + () => { + count++ + }, + { baseDir, delayMs: 200 } + ) + await api.fs.writeTextFile(`${watched}/debounced.txt`, 'a', { + baseDir + }) + const deadline = Date.now() + 10_000 + while (count === 0) { + if (Date.now() > deadline) { + throw new Error('no debounced watch event received') + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + const afterFirst = count + unwatch() + await api.fs.writeTextFile(`${watched}/after-unwatch.txt`, 'b', { + baseDir + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { afterFirst, afterUnwatch: count } + }, `${dir}/debounced`) + expect(result.afterFirst).toBeGreaterThan(0) + expect(result.afterUnwatch).toBe(result.afterFirst) + }) +}) diff --git a/packages/api-e2e/test/specs/global-shortcut.spec.ts b/packages/api-e2e/test/specs/global-shortcut.spec.ts new file mode 100644 index 0000000..89aa300 --- /dev/null +++ b/packages/api-e2e/test/specs/global-shortcut.spec.ts @@ -0,0 +1,103 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + tauriError, + describePlugin, + platform +} from '../helpers/index.js' + +// Triggering a shortcut needs OS-level synthetic input the WebDriver session +// cannot produce, so the specs cover the registry (register / isRegistered / +// unregister / unregisterAll) and the error paths. The plugin is desktop-only, +// so the whole suite is skipped on mobile. + +describePlugin('global-shortcut', { desktopOnly: true }, () => { + afterEach(async () => { + await tauri((api) => api.globalShortcut.unregisterAll()) + }) + + it('register and unregister update isRegistered', async () => { + const result = await tauri(async (api) => { + const shortcut = 'CommandOrControl+Shift+F9' + const before = await api.globalShortcut.isRegistered(shortcut) + await api.globalShortcut.register(shortcut, () => {}) + const registered = await api.globalShortcut.isRegistered(shortcut) + await api.globalShortcut.unregister(shortcut) + const after = await api.globalShortcut.isRegistered(shortcut) + return { before, registered, after } + }) + expect(result).toEqual({ before: false, registered: true, after: false }) + }) + + it('register accepts a list of shortcuts and unregister a list too', async () => { + const result = await tauri(async (api) => { + const shortcuts = ['Alt+Shift+F7', 'Alt+Shift+F8'] + await api.globalShortcut.register(shortcuts, () => {}) + const registered = await Promise.all( + shortcuts.map((s) => api.globalShortcut.isRegistered(s)) + ) + await api.globalShortcut.unregister(shortcuts) + const after = await Promise.all( + shortcuts.map((s) => api.globalShortcut.isRegistered(s)) + ) + return { registered, after } + }) + expect(result.registered).toEqual([true, true]) + expect(result.after).toEqual([false, false]) + }) + + it('unregisterAll clears every registration', async () => { + const result = await tauri(async (api) => { + await api.globalShortcut.register('Alt+Shift+F5', () => {}) + await api.globalShortcut.register('Alt+Shift+F6', () => {}) + await api.globalShortcut.unregisterAll() + return [ + await api.globalShortcut.isRegistered('Alt+Shift+F5'), + await api.globalShortcut.isRegistered('Alt+Shift+F6') + ] + }) + expect(result).toEqual([false, false]) + }) + + it('shortcut names are normalized when checking registrations', async () => { + const result = await tauri(async (api) => { + await api.globalShortcut.register('CmdOrCtrl+Alt+F10', () => {}) + return { + aliased: await api.globalShortcut.isRegistered( + 'CommandOrControl+Alt+F10' + ), + reordered: await api.globalShortcut.isRegistered('Alt+CmdOrCtrl+F10') + } + }) + expect(result).toEqual({ aliased: true, reordered: true }) + }) + + it('registering the same shortcut twice rejects', async () => { + const message = await tauriError(async (api) => { + await api.globalShortcut.register('Alt+Shift+F11', () => {}) + try { + await api.globalShortcut.register('Alt+Shift+F11', () => {}) + } finally { + await api.globalShortcut.unregister('Alt+Shift+F11') + } + }) + // macOS does not check for duplicates itself: the OS rejects the second + // `RegisterEventHotKey` call and the error is the generic one + expect(message).toMatch( + platform === 'darwin' + ? /RegisterEventHotKey failed for F11/ + : /already registered/i + ) + }) + + it('rejects shortcuts that cannot be parsed', async () => { + const message = await tauriError((api) => + api.globalShortcut.register('NotAKey+Nope', () => {}) + ) + expect(message).toMatch(/NotAKey/) + }) +}) diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts new file mode 100644 index 0000000..cd28320 --- /dev/null +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -0,0 +1,149 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, tauriError, describePlugin } from '../helpers/index.js' + +// The example spawns an echo server on this port: it replies with the request +// body and the request headers, and sets a `session-token` cookie on requests +// that do not carry one. It is also the only `http://` origin in the example's +// http scope. +const echoServer = 'http://localhost:3003' + +describePlugin('http', () => { + it('fetch performs a GET and exposes status, url and headers', async () => { + const response = await tauri(async (api, url) => { + const response = await api.http.fetch(url, { + headers: { 'x-e2e-header': 'present' } + }) + return { + ok: response.ok, + status: response.status, + url: response.url, + // the echo server mirrors the request headers back + echoedHeader: response.headers.get('x-e2e-header'), + body: await response.text() + } + }, echoServer) + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + expect(response.url).toBe(`${echoServer}/`) + expect(response.echoedHeader).toBe('present') + expect(response.body).toBe('') + }) + + it('fetch sends a JSON body and parses the echoed response', async () => { + const payload = { message: 'hello from e2e', nested: { list: [1, 2, 3] } } + const response = await tauri( + async (api, url, payload) => { + const response = await api.http.fetch(`${url}/json`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload) + }) + return { + status: response.status, + contentType: response.headers.get('content-type'), + body: (await response.json()) as unknown + } + }, + echoServer, + payload + ) + expect(response.status).toBe(200) + expect(response.contentType).toBe('application/json') + expect(response.body).toEqual(payload) + }) + + it('fetch sends binary bodies and reads them back as bytes', async () => { + const bytes = [0, 1, 2, 127, 128, 254, 255] + const echoed = await tauri( + async (api, url, bytes) => { + const response = await api.http.fetch(`${url}/bytes`, { + method: 'PUT', + body: new Uint8Array(bytes) + }) + return Array.from(new Uint8Array(await response.arrayBuffer())) + }, + echoServer, + bytes + ) + expect(echoed).toEqual(bytes) + }) + + it('fetch sends multipart form data', async () => { + const body = await tauri(async (api, url) => { + const form = new FormData() + form.append('foo', 'baz') + form.append('bar', 'qux') + const response = await api.http.fetch(`${url}/form`, { + method: 'POST', + body: form + }) + return { + contentType: response.headers.get('content-type'), + text: await response.text() + } + }, echoServer) + expect(body.contentType).toMatch(/^multipart\/form-data; boundary=/) + expect(body.text).toContain('name="foo"') + expect(body.text).toContain('baz') + expect(body.text).toContain('name="bar"') + expect(body.text).toContain('qux') + }) + + it('the cookie jar stores and replays cookies across requests', async () => { + const result = await tauri(async (api, url) => { + // The jar is persisted in the app data dir, so an earlier run (or the + // requests above) may already hold the cookie: the first request then + // replays it instead of being handed a new one. + const first = await api.http.fetch(`${url}/cookies`) + await first.text() + // Either way the jar attaches it to the next request, which the echo + // server mirrors back as a `cookie` header without setting a new one. + const second = await api.http.fetch(`${url}/cookies`) + await second.text() + return { + setCookie: first.headers.get('set-cookie'), + replayedFirst: first.headers.get('cookie'), + replayed: second.headers.get('cookie'), + setAgain: second.headers.get('set-cookie') + } + }, echoServer) + if (result.replayedFirst === null) { + expect(result.setCookie).toMatch(/^session-token=test-value/) + } else { + expect(result.replayedFirst).toContain('session-token=test-value') + } + expect(result.replayed).toContain('session-token=test-value') + expect(result.setAgain).toBeNull() + }) + + it('fetch can be aborted', async () => { + const message = await tauriError(async (api, url) => { + const controller = new AbortController() + controller.abort() + await api.http.fetch(`${url}/aborted`, { signal: controller.signal }) + }, echoServer) + expect(message).toMatch(/abort|cancel/i) + }) + + it('rejects URLs outside the configured scope', async () => { + const message = await tauriError((api) => + api.http.fetch('http://localhost:3999/not-in-scope') + ) + expect(message).toMatch(/url not allowed on the configured scope/) + }) + + it('network failures reject', async () => { + // Every in-scope origin is reachable, so route the request through a proxy + // nothing listens on to force a connection error. + const message = await tauriError( + (api, url) => + api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }), + echoServer + ) + expect(message).toMatch(/error sending request/) + }) +}) diff --git a/packages/api-e2e/test/specs/log.spec.ts b/packages/api-e2e/test/specs/log.spec.ts new file mode 100644 index 0000000..74467f6 --- /dev/null +++ b/packages/api-e2e/test/specs/log.spec.ts @@ -0,0 +1,188 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, describePlugin, isMobile } from '../helpers/index.js' + +// The example registers the plugin with a `Webview` target, so records logged +// from the page (and from Rust) are forwarded back to `attachLogger`. +// +// The plugin's default format differs per platform: `[date][time][target][level] +// message` on desktop, and just `[target] message` on mobile, where the platform +// logger (logcat / os_log) already stamps the time and level. + +interface Record { + level: number + message: string +} + +type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace' + +/** Logs `message` at `level` and resolves with the records `attachLogger` saw. */ +function logAndCollect(level: Level, message: string) { + return tauri( + (api, level, message) => + new Promise((resolve, reject) => { + const records: Record[] = [] + api.log + .attachLogger((record) => { + if (record.message.includes(message)) { + records.push(record) + } + }) + .then(async (detach) => { + // eslint-disable-next-line security/detect-object-injection + await api.log[level](message) + setTimeout(() => { + detach() + resolve(records) + }, 1000) + }) + .catch(reject) + }), + level, + message + ) +} + +describePlugin('log', () => { + it('attachLogger receives records logged from the webview', async () => { + const records = await logAndCollect('info', 'info record from e2e') + expect(records).toHaveLength(1) + expect(records[0].level).toBe(3) // LogLevel.Info + expect(records[0].message).toMatch( + isMobile + ? /\[webview[^\]]*\] info record from e2e$/ + : /\[webview[^\]]*\]\[INFO\] info record from e2e$/ + ) + }) + + it('records carry the level they were logged at', async () => { + const error = await logAndCollect('error', 'error record from e2e') + const warn = await logAndCollect('warn', 'warn record from e2e') + expect(error[0].level).toBe(5) // LogLevel.Error + expect(warn[0].level).toBe(4) // LogLevel.Warn + if (!isMobile) { + // the level is only part of the formatted message on desktop + expect(error[0].message).toContain('[ERROR]') + expect(warn[0].message).toContain('[WARN]') + } + }) + + it('records below the configured level are dropped', async () => { + // the example sets the level filter to Info + const debug = await logAndCollect('debug', 'debug record from e2e') + const trace = await logAndCollect('trace', 'trace record from e2e') + expect(debug).toHaveLength(0) + expect(trace).toHaveLength(0) + }) + + it('log options are accepted', async () => { + const records = await tauri( + (api, needle) => + new Promise((resolve, reject) => { + const records: Record[] = [] + api.log + .attachLogger((record) => { + if (record.message.includes(needle)) records.push(record) + }) + .then(async (detach) => { + await api.log.info(needle, { + file: 'e2e.spec.ts', + line: 42, + keyValues: { suite: 'plugins-e2e' } + }) + setTimeout(() => { + detach() + resolve(records) + }, 1000) + }) + .catch(reject) + }), + 'record with options from e2e' + ) + expect(records).toHaveLength(1) + expect(records[0].level).toBe(3) + }) + + it('detaching the logger stops delivery', async () => { + const count = await tauri( + (api, needle) => + new Promise((resolve, reject) => { + let count = 0 + api.log + .attachLogger((record) => { + if (record.message.includes(needle)) count++ + }) + .then(async (detach) => { + await api.log.info(needle) + await new Promise((r) => setTimeout(r, 500)) + detach() + await api.log.info(needle) + await new Promise((r) => setTimeout(r, 1000)) + resolve(count) + }) + .catch(reject) + }), + 'detached record from e2e' + ) + expect(count).toBe(1) + }) + + it('attachConsole forwards records to the console', async () => { + const forwarded = await tauri( + (api, needle) => + new Promise((resolve, reject) => { + const seen: string[] = [] + // Info records are forwarded to `console.info` + const original = console.info + console.info = (...args: unknown[]) => { + const text = args.map(String).join(' ') + if (text.includes(needle)) seen.push(text) + original.apply(console, args) + } + api.log + .attachConsole() + .then(async (detach) => { + await api.log.info(needle) + setTimeout(() => { + detach() + console.info = original + resolve(seen) + }, 1000) + }) + .catch(reject) + }), + 'console record from e2e' + ) + expect(forwarded.length).toBeGreaterThan(0) + }) + + it('records logged from Rust are forwarded to the webview too', async () => { + // the example's `log_operation` command logs its arguments at Info + const message = await tauri( + (api, needle) => + new Promise((resolve, reject) => { + api.log + .attachLogger((record) => { + if (record.message.includes(needle)) resolve(record.message) + }) + .then(() => + api.core.invoke('log_operation', { + event: 'tauri-click', + payload: needle + }) + ) + .catch(reject) + setTimeout(() => reject(new Error('record not received')), 5000) + }), + 'rust log from e2e' + ) + expect(message).toContain( + isMobile ? '[api_lib::cmd] tauri-click' : '[INFO] tauri-click' + ) + expect(message).toContain('rust log from e2e') + expect(message).not.toContain('[webview') + }) +}) diff --git a/packages/api-e2e/test/specs/notification.spec.ts b/packages/api-e2e/test/specs/notification.spec.ts new file mode 100644 index 0000000..c67256c --- /dev/null +++ b/packages/api-e2e/test/specs/notification.spec.ts @@ -0,0 +1,141 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + tauriError, + describePlugin, + itDesktop, + itOn +} from '../helpers/index.js' + +// Whether a notification actually shows up depends on the desktop session +// (a notification daemon on Linux, the app's registration on Windows and +// macOS), which the suite cannot observe, and sending is fire-and-forget, so +// only the permission model is covered on desktop. +// +// The permission specs are desktop-only: mobile starts out ungranted and +// `requestPermission` puts up a system dialog the session would then block on. +// +// Scheduling, action types, channels, the pending/active lists and the +// listeners are only implemented on mobile. None of them need the permission, +// so they are covered there on an app that has not been granted it. + +describePlugin('notification', () => { + itDesktop('permission is granted on desktop', async () => { + const result = await tauri(async (api) => ({ + granted: await api.notification.isPermissionGranted(), + requested: await api.notification.requestPermission() + })) + expect(result).toEqual({ granted: true, requested: 'granted' }) + }) + + itDesktop('the plugin overrides window.Notification', async () => { + const result = await tauri(async () => ({ + permission: window.Notification.permission, + requested: await window.Notification.requestPermission() + })) + expect(result).toEqual({ permission: 'granted', requested: 'granted' }) + }) + + itOn( + ['android', 'ios'], + 'action types register, and cancel/remove leave nothing pending or active', + async () => { + const result = await tauri(async (api) => { + await api.notification.registerActionTypes([ + { + id: 'e2e-actions', + actions: [ + { id: 'reply', title: 'Reply', input: true }, + { id: 'dismiss', title: 'Dismiss', destructive: true } + ] + } + ]) + await api.notification.cancel([424242]) + await api.notification.cancelAll() + await api.notification.removeActive([{ id: 424242 }]) + await api.notification.removeAllActive() + return { + pending: await api.notification.pending(), + active: await api.notification.active() + } + }) + expect(result).toEqual({ pending: [], active: [] }) + } + ) + + itOn('android', 'channels can be created, listed and removed', async () => { + const result = await tauri(async (api) => { + const { Importance, Visibility } = api.notification + await api.notification.createChannel({ + id: 'e2e-channel', + name: 'e2e channel', + description: 'created by the e2e suite', + importance: Importance.High, + visibility: Visibility.Public, + vibration: true + }) + const created = (await api.notification.channels()).find( + (channel) => channel.id === 'e2e-channel' + ) + await api.notification.removeChannel('e2e-channel') + const removed = (await api.notification.channels()).some( + (channel) => channel.id === 'e2e-channel' + ) + return { + created: created && { + name: created.name, + description: created.description, + importance: created.importance, + vibration: created.vibration + }, + removed + } + }) + expect(result).toEqual({ + created: { + name: 'e2e channel', + description: 'created by the e2e suite', + importance: 4, // Importance.High + vibration: true + }, + removed: false + }) + }) + + itOn('ios', 'channels are not implemented', async () => { + for (const call of ['create', 'remove', 'list'] as const) { + const error = await tauriError( + (api, call) => + call === 'create' + ? api.notification.createChannel({ id: 'e2e', name: 'e2e' }) + : call === 'remove' + ? api.notification.removeChannel('e2e') + : api.notification.channels(), + call + ) + expect(error).toMatch(/not implemented/) + } + }) + + itOn( + ['android', 'ios'], + 'onNotificationReceived and onAction listeners register and unregister', + async () => { + const result = await tauri(async (api) => { + const received = await api.notification.onNotificationReceived(() => {}) + const action = await api.notification.onAction(() => {}) + await received.unregister() + await action.unregister() + return { received: received.event, action: action.event } + }) + expect(result).toEqual({ + received: 'notification', + action: 'actionPerformed' + }) + } + ) +}) diff --git a/packages/api-e2e/test/specs/opener.spec.ts b/packages/api-e2e/test/specs/opener.spec.ts new file mode 100644 index 0000000..d5e1b73 --- /dev/null +++ b/packages/api-e2e/test/specs/opener.spec.ts @@ -0,0 +1,46 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauriError, describePlugin } from '../helpers/index.js' + +// A successful open launches an external application (browser, file manager) +// the suite cannot control or close, so only the scope enforcement is covered. +// The example allows `mailto:`, `tel:`, `http(s)://` URLs (opener:default), +// `https://` URLs specifically with `inAppBrowser`, and paths under `$APPDATA`. + +describePlugin('opener', () => { + it('openUrl rejects URL schemes outside the scope', async () => { + const message = await tauriError((api) => + api.opener.openUrl('ftp://example.com/file') + ) + expect(message).toMatch( + /Not allowed to open url ftp:\/\/example\.com\/file/ + ) + }) + + it('openUrl rejects an app that is not in the scope for the URL', async () => { + // `inAppBrowser` is only allowed for `https://` URLs + const message = await tauriError((api) => + api.opener.openUrl('http://example.com', 'inAppBrowser') + ) + expect(message).toMatch(/Not allowed to open url http:\/\/example\.com/) + }) + + it('openPath rejects paths outside the scope', async () => { + const message = await tauriError(async (api) => + api.opener.openPath(await api.path.join(await api.path.homeDir(), 'e2e')) + ) + expect(message).toMatch(/Not allowed to open path/) + }) + + it('revealItemInDir rejects paths that do not exist', async () => { + const message = await tauriError(async (api) => + api.opener.revealItemInDir( + await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e') + ) + ) + expect(message).toMatch(/os error 2/) + }) +}) diff --git a/packages/api-e2e/test/specs/os.spec.ts b/packages/api-e2e/test/specs/os.spec.ts new file mode 100644 index 0000000..8248475 --- /dev/null +++ b/packages/api-e2e/test/specs/os.spec.ts @@ -0,0 +1,109 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import os from 'node:os' +import { expect } from '@wdio/globals' +import { + tauri, + describePlugin, + itDesktop, + platform, + isMobile +} from '../helpers/index.js' + +// Everything the plugin reports is baked in at compile time, so it describes +// the *app's* platform — the host for the desktop suite, but the emulator or +// simulator for the mobile ones, which is why nothing here compares against +// Node's view of the host unless the two are the same machine. + +const nodePlatformToTauri: Record = { + linux: 'linux', + win32: 'windows', + darwin: 'macos', + freebsd: 'freebsd', + openbsd: 'openbsd', + android: 'android', + ios: 'ios' +} + +const nodeArchToTauri: Record = { + x64: 'x86_64', + ia32: 'x86', + arm64: 'aarch64', + arm: 'arm', + riscv64: 'riscv64', + ppc64: 'powerpc64', + s390x: 's390x' +} + +// `Arch` in the plugin's guest-js. +const architectures = [ + 'x86', + 'x86_64', + 'arm', + 'aarch64', + 'mips', + 'mips64', + 'powerpc', + 'powerpc64', + 'riscv64', + 's390x', + 'sparc64' +] + +describePlugin('os', () => { + it('platform, type and family match the target', async () => { + const info = await tauri((api) => ({ + platform: api.os.platform(), + type: api.os.type(), + family: api.os.family() + })) + // eslint-disable-next-line security/detect-object-injection + expect(info.platform).toBe(nodePlatformToTauri[platform]) + // eslint-disable-next-line security/detect-object-injection + expect(info.type).toBe(nodePlatformToTauri[platform]) + expect(info.family).toBe(platform === 'win32' ? 'windows' : 'unix') + }) + + it('arch reports a known architecture', async () => { + const arch = await tauri((api) => api.os.arch()) + if (isMobile) { + // The device/simulator is not necessarily the host's architecture (see + // `E2E_ANDROID_TARGET` / `E2E_IOS_TARGET`). + expect(architectures).toContain(arch) + } else { + expect(arch).toBe(nodeArchToTauri[process.arch]) + } + }) + + it('eol and exeExtension match the platform conventions', async () => { + const info = await tauri((api) => ({ + eol: api.os.eol(), + exeExtension: api.os.exeExtension() + })) + expect(info.eol).toBe(platform === 'win32' ? '\r\n' : '\n') + expect(info.exeExtension).toBe(platform === 'win32' ? 'exe' : '') + }) + + it('locale is null or a language tag', async () => { + const locale = await tauri((api) => api.os.locale()) + // The plugin forwards the environment's POSIX locale as-is, so a host with + // no locale configured (CI runners default to `LANG=C.UTF-8`) reports the + // POSIX default instead of a language tag. + if (locale !== null && locale !== 'C' && locale !== 'POSIX') { + // e.g. `en-US` + const [language, ...subtags] = locale.split(/[-_]/) + expect(language).toMatch(/^[A-Za-z]{2,3}$/) + for (const subtag of subtags) { + expect(subtag).toMatch(/^[A-Za-z0-9]+$/) + } + } + }) + + itDesktop('hostname matches the host', async () => { + const hostname = await tauri((api) => api.os.hostname()) + // Windows can report the name in a different case than Node does. + expect(hostname!.toLowerCase()).toBe(os.hostname().toLowerCase()) + }) +}) diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts new file mode 100644 index 0000000..7fdc009 --- /dev/null +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -0,0 +1,234 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + isMobile, + type CommonPluginApi, + type DesktopPluginApi, + type MobilePluginApi +} from '../helpers/index.js' + +/** + * The members each plugin's `api-iife.js` is expected to define on + * `window.__TAURI__.`. This is the one place the suite covers the + * plugins whose commands cannot be driven from a WebDriver session (dialog + * blocks on native UI, process terminates the app, the mobile plugins need + * hardware or native UI), and it catches a plugin whose global API script is + * missing from its `build.rs`. + */ +type Surface = { [P in keyof T]: (keyof T[P])[] } + +/** Plugins the example registers on every platform. */ +const commonSurface: Surface = { + clipboardManager: [ + 'writeText', + 'readText', + 'writeHtml', + 'clear', + 'readImage', + 'writeImage' + ], + deepLink: [ + 'getCurrent', + 'register', + 'unregister', + 'isRegistered', + 'onOpenUrl' + ], + dialog: ['open', 'save', 'message', 'ask', 'confirm'], + fs: [ + 'BaseDirectory', + 'FileHandle', + 'SeekMode', + 'create', + 'open', + 'copyFile', + 'mkdir', + 'readDir', + 'readFile', + 'readTextFile', + 'readTextFileLines', + 'remove', + 'rename', + 'stat', + 'lstat', + 'truncate', + 'writeFile', + 'writeTextFile', + 'exists', + 'watch', + 'watchImmediate', + 'size' + ], + http: ['fetch'], + log: [ + 'LogLevel', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'attachLogger', + 'attachConsole' + ], + notification: [ + 'isPermissionGranted', + 'requestPermission', + 'sendNotification', + 'registerActionTypes', + 'pending', + 'cancel', + 'cancelAll', + 'active', + 'removeActive', + 'removeAllActive', + 'createChannel', + 'removeChannel', + 'channels', + 'onNotificationReceived', + 'onAction' + ], + opener: ['openUrl', 'openPath', 'revealItemInDir'], + os: [ + 'eol', + 'platform', + 'family', + 'version', + 'type', + 'arch', + 'locale', + 'exeExtension', + 'hostname' + ], + process: ['exit', 'relaunch'], + shell: ['Command', 'Child', 'EventEmitter', 'open'], + // the `Database` class itself + sql: ['load', 'get'], + store: ['load', 'getStore', 'LazyStore', 'Store'], + stronghold: ['Location', 'Client', 'Store', 'Vault', 'Stronghold'], + upload: ['download', 'upload', 'HttpMethod'], + // the `WebSocket` class itself + websocket: ['connect'] +} + +/** Plugins the example only registers on desktop. */ +const desktopSurface: Surface = { + autostart: ['enable', 'disable', 'isEnabled'], + cli: ['getMatches'], + globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'], + positioner: [ + 'Position', + 'moveWindow', + 'moveWindowConstrained', + 'handleIconState' + ], + updater: ['check', 'Update'], + windowState: [ + 'StateFlags', + 'restoreState', + 'restoreStateCurrent', + 'saveWindowState', + 'filename' + ] +} + +/** Plugins the example only registers on mobile. */ +const mobileSurface: Surface = { + barcodeScanner: [ + 'Format', + 'scan', + 'cancel', + 'checkPermissions', + 'requestPermissions', + 'openAppSettings' + ], + biometric: ['BiometryType', 'checkStatus', 'authenticate'], + geolocation: [ + 'watchPosition', + 'getCurrentPosition', + 'clearWatch', + 'checkPermissions', + 'requestPermissions' + ], + // `ImpactFeedbackStyle` and `NotificationFeedbackType` are type aliases, so + // they are not part of the runtime namespace. + haptics: [ + 'vibrate', + 'impactFeedback', + 'notificationFeedback', + 'selectionFeedback' + ], + nfc: [ + 'NFCTypeNameFormat', + 'TechKind', + 'RTD_TEXT', + 'RTD_URI', + 'record', + 'textRecord', + 'uriRecord', + 'scan', + 'write', + 'isAvailable' + ] +} + +const surface = { + ...commonSurface, + ...(isMobile ? mobileSurface : desktopSurface) +} as Record + +/** The other platform's plugins, which must *not* be in this build. */ +const foreign = Object.keys(isMobile ? desktopSurface : mobileSurface) + +describe('plugin globals', () => { + it('every plugin this platform registers exposes its API on window.__TAURI__', async () => { + const missing = await tauri( + (api, plugins) => + plugins.filter((plugin) => { + // eslint-disable-next-line security/detect-object-injection + const global = (api as unknown as Record)[plugin] + // a module namespace, or the class a default-export-only module (sql, websocket) defines + return typeof global !== 'object' && typeof global !== 'function' + }), + Object.keys(surface) + ) + expect(missing).toEqual([]) + }) + + it('the other platform’s plugins are not in the build', async () => { + // Their Rust crates are target-gated in the example's Cargo.toml, so their + // `global_api_script_path` is never injected either. + const present = await tauri( + (api, plugins) => + plugins.filter( + (plugin) => + // eslint-disable-next-line security/detect-object-injection + (api as unknown as Record)[plugin] !== undefined + ), + foreign + ) + expect(present).toEqual([]) + }) + + for (const [plugin, members] of Object.entries(surface)) { + it(`${plugin} exposes its documented members`, async () => { + const missing = await tauri( + (api, plugin, members) => { + const namespaces = api as unknown as Record< + string, + Record + > + // eslint-disable-next-line security/detect-object-injection + const namespace = namespaces[plugin] + return members.filter((member) => !(member in namespace)) + }, + plugin, + members + ) + expect(missing).toEqual([]) + }) + } +}) diff --git a/packages/api-e2e/test/specs/positioner.spec.ts b/packages/api-e2e/test/specs/positioner.spec.ts new file mode 100644 index 0000000..f2cfadf --- /dev/null +++ b/packages/api-e2e/test/specs/positioner.spec.ts @@ -0,0 +1,137 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { tauri, tauriError, describePlugin, itWm } from '../helpers/index.js' + +// Positions are computed from the window's current monitor and outer size, so +// the specs compute the expected physical position the same way and compare +// once the window manager has applied the move. The tray positions need the +// tray icon's rect, which normally comes from a click on the icon; the spec +// hands one in through `handleIconState`, as a JS tray event handler would. +// The moved window is not put back: each spec file gets a fresh app. + +interface Geometry { + monitor: { x: number; y: number; width: number; height: number } + window: { width: number; height: number } +} + +/** Moves the main window and reports where it ended up along with the geometry the position is derived from. */ +function moveAndMeasure(position: string, constrained = false) { + return tauri( + async (api, position, constrained) => { + const win = api.window.getCurrentWindow() + const { Position } = api.positioner + const value = Position[position as keyof typeof Position] + await (constrained + ? api.positioner.moveWindowConstrained(value) + : api.positioner.moveWindow(value)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const monitor = await api.window.currentMonitor() + const size = await win.outerSize() + const outer = await win.outerPosition() + return { + monitor: { + x: monitor!.position.x, + y: monitor!.position.y, + width: monitor!.size.width, + height: monitor!.size.height + }, + window: { width: size.width, height: size.height }, + position: { x: outer.x, y: outer.y } + } + }, + position, + constrained + ) +} + +function center({ monitor, window }: Geometry) { + return { + x: monitor.x + Math.trunc(monitor.width / 2) - Math.trunc(window.width / 2), + y: + monitor.y + Math.trunc(monitor.height / 2) - Math.trunc(window.height / 2) + } +} + +describePlugin('positioner', { desktopOnly: true }, () => { + // must run before any tray rect is handed in + it('tray positions are rejected until the tray icon reported its rect', async () => { + const error = await tauriError((api) => + api.positioner.moveWindow(api.positioner.Position.TrayCenter) + ) + expect(error).toMatch(/Tray position not set/) + }) + + it('the Position enum matches the plugin', async () => { + const names = await tauri((api) => + Object.keys(api.positioner.Position).filter((key) => isNaN(Number(key))) + ) + expect(names).toEqual([ + 'TopLeft', + 'TopRight', + 'BottomLeft', + 'BottomRight', + 'TopCenter', + 'BottomCenter', + 'LeftCenter', + 'RightCenter', + 'Center', + 'TrayLeft', + 'TrayBottomLeft', + 'TrayRight', + 'TrayBottomRight', + 'TrayCenter', + 'TrayBottomCenter' + ]) + }) + + itWm('moveWindow centers the window on its monitor', async () => { + const result = await moveAndMeasure('Center') + expect(result.position).toEqual(center(result)) + }) + + itWm( + 'moveWindowConstrained behaves like moveWindow for screen positions', + async () => { + const result = await moveAndMeasure('Center', true) + expect(result.position).toEqual(center(result)) + } + ) + + itWm('tray positions are relative to the reported tray rect', async () => { + // a tray "icon" in the middle of the window's monitor, far from any edge, + // so neither the OS-specific flips nor the constraint kick in + const tray = await tauri(async (api) => { + const monitor = await api.window.currentMonitor() + const rect = { + x: monitor!.position.x + Math.trunc(monitor!.size.width / 2), + y: monitor!.position.y + Math.trunc(monitor!.size.height / 2), + width: 20, + height: 20 + } + await api.positioner.handleIconState({ + type: 'Click', + id: 'e2e', + position: new api.dpi.PhysicalPosition(rect.x, rect.y), + rect: { + position: new api.dpi.PhysicalPosition(rect.x, rect.y), + size: new api.dpi.PhysicalSize(rect.width, rect.height) + }, + button: 'Left', + buttonState: 'Down' + }) + return rect + }) + + const bottomLeft = await moveAndMeasure('TrayBottomLeft') + expect(bottomLeft.position).toEqual({ x: tray.x, y: tray.y }) + + const bottomCenter = await moveAndMeasure('TrayBottomCenter', true) + expect(bottomCenter.position).toEqual({ + x: tray.x + tray.width / 2 - Math.trunc(bottomCenter.window.width / 2), + y: tray.y + }) + }) +}) diff --git a/packages/api-e2e/test/specs/shell.spec.ts b/packages/api-e2e/test/specs/shell.spec.ts new file mode 100644 index 0000000..d43ce30 --- /dev/null +++ b/packages/api-e2e/test/specs/shell.spec.ts @@ -0,0 +1,238 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { expect } from '@wdio/globals' +import { + tauri, + tauriError, + describePlugin, + platform +} from '../helpers/index.js' + +// The example's shell scope allows `sh -c + + + + +
+

Welcome to Tauri!

+ +
+ +

Click on the Tauri logo to learn more about the framework

+ +
+ +
+
+

Requested intent:

+

+
+ +
+

initial intent:

+

+
+ +
+

updated intent by event:

+

+
+
+ + diff --git a/plugins/deep-link/examples/app/package.json b/plugins/deep-link/examples/app/package.json new file mode 100644 index 0000000..595af97 --- /dev/null +++ b/plugins/deep-link/examples/app/package.json @@ -0,0 +1,21 @@ +{ + "name": "deep-link-example", + "private": true, + "version": "2.2.13", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-deep-link": "workspace:*" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.4", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/plugins/deep-link/examples/app/public/svelte.svg b/plugins/deep-link/examples/app/public/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/plugins/deep-link/examples/app/public/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/public/tauri.svg b/plugins/deep-link/examples/app/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/plugins/deep-link/examples/app/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/deep-link/examples/app/public/vite.svg b/plugins/deep-link/examples/app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/plugins/deep-link/examples/app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/.gitignore b/plugins/deep-link/examples/app/src-tauri/.gitignore new file mode 100644 index 0000000..877b3f7 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +/gen/schemas + +.cargo \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/Cargo.toml b/plugins/deep-link/examples/app/src-tauri/Cargo.toml new file mode 100644 index 0000000..1ebb5ac --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "deep-link-example" +version = "0.0.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true, features = ["wry", "common-controls-v6", "x11"] } +tauri-plugin-deep-link = { path = "../../../" } +tauri-plugin-log = { path = "../../../../log" } +tauri-plugin-single-instance = { path = "../../../../single-instance", features = [ + "deep-link", +] } +log = "0.4" diff --git a/plugins/deep-link/examples/app/src-tauri/apple-app-site-association b/plugins/deep-link/examples/app/src-tauri/apple-app-site-association new file mode 100644 index 0000000..f94da1b --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/apple-app-site-association @@ -0,0 +1,15 @@ +{ + "applinks": { + "details": [ + { + "appIDs": [ "DEVELOPMENT_TEAM_ID_HERE.com.tauri.deep-link-example" ], + "components": [ + { + "/": "/open/*", + "comment": "Matches any URL whose path starts with /open/" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/assetlinks.json.txt b/plugins/deep-link/examples/app/src-tauri/assetlinks.json.txt new file mode 100644 index 0000000..0f09c9a --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/assetlinks.json.txt @@ -0,0 +1 @@ +see https://fabianlars.de/.well-known/assetlinks.json \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/build.rs b/plugins/deep-link/examples/app/src-tauri/build.rs new file mode 100644 index 0000000..5ebf8d2 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/build.rs @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +fn main() { + tauri_build::build() +} diff --git a/plugins/deep-link/examples/app/src-tauri/capabilities/app.json b/plugins/deep-link/examples/app/src-tauri/capabilities/app.json new file mode 100644 index 0000000..a4bc7b4 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/capabilities/app.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "run-app-base", + "description": "Base permissions to run the app", + "windows": ["main"], + "permissions": [ + "core:default", + "deep-link:allow-get-current", + "deep-link:default" + ] +} diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/.editorconfig b/plugins/deep-link/examples/app/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/.gitignore b/plugins/deep-link/examples/app/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..b248203 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/.gitignore b/plugins/deep-link/examples/app/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..1efb55b --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/java/com/tauri/deep_link_example/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/build.gradle.kts b/plugins/deep-link/examples/app/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..13ec1ff --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,70 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "com.tauri.deep_link_example" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.tauri.deep_link_example" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/proguard-rules.pro b/plugins/deep-link/examples/app/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..591f3c6 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/java/com/tauri/deep_link_example/MainActivity.kt b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/java/com/tauri/deep_link_example/MainActivity.kt new file mode 100644 index 0000000..313161c --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/java/com/tauri/deep_link_example/MainActivity.kt @@ -0,0 +1,11 @@ +package com.tauri.deep_link_example + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..2777e73 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/colors.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/strings.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5c31c3f --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + deep-link-example + deep-link-example + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/themes.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2777e73 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/build.gradle.kts b/plugins/deep-link/examples/app/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/build.gradle.kts b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/BuildTask.kt b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/BuildTask.kt new file mode 100644 index 0000000..f987482 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/BuildTask.kt @@ -0,0 +1,52 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + runTauriCli("$executable.cmd") + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/RustPlugin.kt b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/buildSrc/src/main/java/com/tauri/deep_link_example/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/gradle.properties b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew b/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew.bat b/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/plugins/deep-link/examples/app/src-tauri/gen/android/settings.gradle b/plugins/deep-link/examples/app/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/.gitignore b/plugins/deep-link/examples/app/src-tauri/gen/apple/.gitignore new file mode 100644 index 0000000..6726e2f --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/.gitignore @@ -0,0 +1,3 @@ +xcuserdata/ +build/ +Externals/ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000..a6ac2a8 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000..cf265a4 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000..29c9746 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e4adcbc Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..704c929 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2a9fbc2 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2cdf184 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4723e4b Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f26fee4 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "AppIcon-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppIcon-512@2x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/Contents.json b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/ExportOptions.plist b/plugins/deep-link/examples/app/src-tauri/gen/apple/ExportOptions.plist new file mode 100644 index 0000000..0428a17 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + debugging + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/LaunchScreen.storyboard b/plugins/deep-link/examples/app/src-tauri/gen/apple/LaunchScreen.storyboard new file mode 100644 index 0000000..81b5f90 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/LaunchScreen.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Podfile b/plugins/deep-link/examples/app/src-tauri/gen/apple/Podfile new file mode 100644 index 0000000..98045f1 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project + +target 'deep-link-example_iOS' do +platform :ios, '13.0' + # Pods for deep-link-example_iOS +end + +target 'deep-link-example_macOS' do +platform :osx, '11.0' + # Pods for deep-link-example_macOS +end + +# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' + end + end +end diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/bindings/bindings.h b/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/bindings/bindings.h new file mode 100644 index 0000000..5152200 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/bindings/bindings.h @@ -0,0 +1,8 @@ +#pragma once + +namespace ffi { + extern "C" { + void start_app(); + } +} + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/main.mm b/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/main.mm new file mode 100644 index 0000000..7793a9d --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/Sources/deep-link-example/main.mm @@ -0,0 +1,6 @@ +#include "bindings/bindings.h" + +int main(int argc, char * argv[]) { + ffi::start_app(); + return 0; +} diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.pbxproj b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4c01a95 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.pbxproj @@ -0,0 +1,479 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 63; + objects = { + +/* Begin PBXBuildFile section */ + 017AE826151E36372534A964 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED2B1BC06DFE0498ECDEEE51 /* Metal.framework */; }; + 65A8D948440EDAA7F62BA1F4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 486CAFD81CB14F9A2DF72FDF /* Assets.xcassets */; }; + 8267407118D9FF73C271D81B /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9435FC7E183EA6260CE76637 /* QuartzCore.framework */; }; + 8DD47883B792C4FC20927AB4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BDECB1ED2EEEB5A6A8B8372 /* Security.framework */; }; + ABA8D5D86E66C92292DA3B3E /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F316D1CD78DD2E070DA5C17 /* UIKit.framework */; }; + BC36958BBBA7FE61066213D7 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C21C8B4A18EC7D0B5808C10 /* MetalKit.framework */; }; + C384FB77F116B05F8E642CA8 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = BF7ECB9AB55B71692A21D5F7 /* assets */; }; + D01EC573029B7BEC701F6012 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6BDF5DBBA740DA7D86791DEC /* WebKit.framework */; }; + D4D232DBB85C5C1594FACC3D /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = D99665C1C3247732C6BF25F4 /* main.mm */; }; + D7A9EBD47413746EDE96BDF8 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B18865218362A4BE07527DBD /* CoreGraphics.framework */; }; + E26F7FA923DA1EABEE42B63A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 403FB4BAE59F74EE98EF1EC6 /* libapp.a */; }; + E6992F2651B864B15ED14925 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1846ADCEDC2C208E1037ADC6 /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1846ADCEDC2C208E1037ADC6 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 1C21C8B4A18EC7D0B5808C10 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + 1CAAFA750FD735A285DC1238 /* deep-link-example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "deep-link-example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F316D1CD78DD2E070DA5C17 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 3DD32303BEC377C10162CF69 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + 403FB4BAE59F74EE98EF1EC6 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + 486CAFD81CB14F9A2DF72FDF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4A33212233BFAA738F6A46FC /* lib.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = lib.rs; sourceTree = ""; }; + 4BDECB1ED2EEEB5A6A8B8372 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 6BDF5DBBA740DA7D86791DEC /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 8AB0099573FE8BF1DC82CDBA /* deep-link-example_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "deep-link-example_iOS.entitlements"; sourceTree = ""; }; + 9435FC7E183EA6260CE76637 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + AEA78299D25FEC31E2988090 /* main.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = main.rs; sourceTree = ""; }; + B005488D1B56B657AB52E28C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + B18865218362A4BE07527DBD /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + BF7ECB9AB55B71692A21D5F7 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + D99665C1C3247732C6BF25F4 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + ED2B1BC06DFE0498ECDEEE51 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5C340AB143FB1483D2013F8B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E26F7FA923DA1EABEE42B63A /* libapp.a in Frameworks */, + D7A9EBD47413746EDE96BDF8 /* CoreGraphics.framework in Frameworks */, + 017AE826151E36372534A964 /* Metal.framework in Frameworks */, + BC36958BBBA7FE61066213D7 /* MetalKit.framework in Frameworks */, + 8267407118D9FF73C271D81B /* QuartzCore.framework in Frameworks */, + 8DD47883B792C4FC20927AB4 /* Security.framework in Frameworks */, + ABA8D5D86E66C92292DA3B3E /* UIKit.framework in Frameworks */, + D01EC573029B7BEC701F6012 /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 146BAF1D709F8A0FE5B07709 /* Sources */ = { + isa = PBXGroup; + children = ( + 15AD0F4F98577F71B7752D85 /* deep-link-example */, + ); + path = Sources; + sourceTree = ""; + }; + 15AD0F4F98577F71B7752D85 /* deep-link-example */ = { + isa = PBXGroup; + children = ( + D99665C1C3247732C6BF25F4 /* main.mm */, + 9FE22F548D05F1B0C03527E4 /* bindings */, + ); + path = "deep-link-example"; + sourceTree = ""; + }; + 1DC58B1705AA3ECC6B887FE7 = { + isa = PBXGroup; + children = ( + BF7ECB9AB55B71692A21D5F7 /* assets */, + 486CAFD81CB14F9A2DF72FDF /* Assets.xcassets */, + 1846ADCEDC2C208E1037ADC6 /* LaunchScreen.storyboard */, + 7D12035C470ED9DAF55A709E /* deep-link-example_iOS */, + 84EADC52DA26583ACE816A6D /* Externals */, + 146BAF1D709F8A0FE5B07709 /* Sources */, + EEC07EE3E5E2B16228B36C78 /* src */, + 308F912BBFBABA6B939FA2B3 /* Frameworks */, + F9EEBB3248B74B1D6CDA4D84 /* Products */, + ); + sourceTree = ""; + }; + 308F912BBFBABA6B939FA2B3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + B18865218362A4BE07527DBD /* CoreGraphics.framework */, + 403FB4BAE59F74EE98EF1EC6 /* libapp.a */, + ED2B1BC06DFE0498ECDEEE51 /* Metal.framework */, + 1C21C8B4A18EC7D0B5808C10 /* MetalKit.framework */, + 9435FC7E183EA6260CE76637 /* QuartzCore.framework */, + 4BDECB1ED2EEEB5A6A8B8372 /* Security.framework */, + 2F316D1CD78DD2E070DA5C17 /* UIKit.framework */, + 6BDF5DBBA740DA7D86791DEC /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 7D12035C470ED9DAF55A709E /* deep-link-example_iOS */ = { + isa = PBXGroup; + children = ( + 8AB0099573FE8BF1DC82CDBA /* deep-link-example_iOS.entitlements */, + B005488D1B56B657AB52E28C /* Info.plist */, + ); + path = "deep-link-example_iOS"; + sourceTree = ""; + }; + 84EADC52DA26583ACE816A6D /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; + 9FE22F548D05F1B0C03527E4 /* bindings */ = { + isa = PBXGroup; + children = ( + 3DD32303BEC377C10162CF69 /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + EEC07EE3E5E2B16228B36C78 /* src */ = { + isa = PBXGroup; + children = ( + 4A33212233BFAA738F6A46FC /* lib.rs */, + AEA78299D25FEC31E2988090 /* main.rs */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; + F9EEBB3248B74B1D6CDA4D84 /* Products */ = { + isa = PBXGroup; + children = ( + 1CAAFA750FD735A285DC1238 /* deep-link-example.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A1C635908C823A89928264CD /* deep-link-example_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = CC3B2B9068F702AB6ED6E36C /* Build configuration list for PBXNativeTarget "deep-link-example_iOS" */; + buildPhases = ( + E8BEC9005266B4780C27DC05 /* Build Rust Code */, + EAFF3E530DA24F7AB759ABB3 /* Sources */, + 8E775D86229F98E9F18AAAB7 /* Resources */, + 5C340AB143FB1483D2013F8B /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "deep-link-example_iOS"; + packageProductDependencies = ( + ); + productName = "deep-link-example_iOS"; + productReference = 1CAAFA750FD735A285DC1238 /* deep-link-example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + BCB4BA6E81088C5B470E3436 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = 8FCB58B8ADB9F9CB9ECE01FA /* Build configuration list for PBXProject "deep-link-example" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 1DC58B1705AA3ECC6B887FE7; + minimizedProjectReferenceProxies = 1; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A1C635908C823A89928264CD /* deep-link-example_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8E775D86229F98E9F18AAAB7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 65A8D948440EDAA7F62BA1F4 /* Assets.xcassets in Resources */, + E6992F2651B864B15ED14925 /* LaunchScreen.storyboard in Resources */, + C384FB77F116B05F8E642CA8 /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + E8BEC9005266B4780C27DC05 /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EAFF3E530DA24F7AB759ABB3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D4D232DBB85C5C1594FACC3D /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 21DF092E6F2020CEC95B687A /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + 3655D9D0A68600F8988F24EB /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "deep-link-example_iOS/deep-link-example_iOS.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = "Q93MBH6S2F"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = "deep-link-example_iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tauri.deep-link-example; + PRODUCT_NAME = "deep-link-example"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = debug; + }; + 7A19D7CB4CA2808477E73B8A /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + 7C085E1672AF39D75FFD0081 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "deep-link-example_iOS/deep-link-example_iOS.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = "Q93MBH6S2F"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = "deep-link-example_iOS/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tauri.deep-link-example; + PRODUCT_NAME = "deep-link-example"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8FCB58B8ADB9F9CB9ECE01FA /* Build configuration list for PBXProject "deep-link-example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 21DF092E6F2020CEC95B687A /* debug */, + 7A19D7CB4CA2808477E73B8A /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + CC3B2B9068F702AB6ED6E36C /* Build configuration list for PBXNativeTarget "deep-link-example_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3655D9D0A68600F8988F24EB /* debug */, + 7C085E1672AF39D75FFD0081 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = BCB4BA6E81088C5B470E3436 /* Project object */; +} diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..ac90d5a --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + DisableBuildSystemDeprecationDiagnostic + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/xcshareddata/xcschemes/deep-link-example_iOS.xcscheme b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/xcshareddata/xcschemes/deep-link-example_iOS.xcscheme new file mode 100644 index 0000000..a8fbc97 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example.xcodeproj/xcshareddata/xcschemes/deep-link-example_iOS.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/Info.plist b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/Info.plist new file mode 100644 index 0000000..bc74b01 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleURLSchemes + + taurideeplink + + CFBundleURLName + taurideeplink + + + + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/deep-link-example_iOS.entitlements b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/deep-link-example_iOS.entitlements new file mode 100644 index 0000000..3216c74 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/deep-link-example_iOS/deep-link-example_iOS.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.developer.associated-domains + + applinks:fabianlars.de + applinks:tauri.app + + + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src-tauri/gen/apple/project.yml b/plugins/deep-link/examples/app/src-tauri/gen/apple/project.yml new file mode 100644 index 0000000..74d0ab4 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/gen/apple/project.yml @@ -0,0 +1,88 @@ +name: deep-link-example +options: + bundleIdPrefix: com.tauri.deep-link-example + deploymentTarget: + iOS: 13.0 +fileGroups: [../../src] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: deep-link-example + PRODUCT_BUNDLE_IDENTIFIER: com.tauri.deep-link-example +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + deep-link-example_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Assets.xcassets + - path: Externals + - path: deep-link-example_iOS + - path: assets + buildPhase: resources + type: folder + - path: LaunchScreen.storyboard + info: + path: deep-link-example_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: 0.1.0 + CFBundleVersion: '0.1.0' + entitlements: + path: deep-link-example_iOS/deep-link-example_iOS.entitlements + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + base: + ENABLE_BITCODE: false + ARCHS: [arm64] + VALID_ARCHS: arm64 + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: libapp.a + embed: false + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + - sdk: WebKit.framework + preBuildScripts: + - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a diff --git a/plugins/deep-link/examples/app/src-tauri/icons/128x128.png b/plugins/deep-link/examples/app/src-tauri/icons/128x128.png new file mode 100644 index 0000000..77e7d23 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/icons/128x128.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/icons/128x128@2x.png b/plugins/deep-link/examples/app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0f7976f Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/icons/128x128@2x.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/icons/32x32.png b/plugins/deep-link/examples/app/src-tauri/icons/32x32.png new file mode 100644 index 0000000..98fda06 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/icons/32x32.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/icons/icon.ico b/plugins/deep-link/examples/app/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/icons/icon.ico differ diff --git a/plugins/deep-link/examples/app/src-tauri/icons/icon.png b/plugins/deep-link/examples/app/src-tauri/icons/icon.png new file mode 100644 index 0000000..d1756ce Binary files /dev/null and b/plugins/deep-link/examples/app/src-tauri/icons/icon.png differ diff --git a/plugins/deep-link/examples/app/src-tauri/server.js b/plugins/deep-link/examples/app/src-tauri/server.js new file mode 100644 index 0000000..24a6ac6 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/server.js @@ -0,0 +1,31 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import http from 'http' +import fs from 'fs' +import path from 'path' +import * as url from 'url' +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +const port = 8125 + +http + .createServer(function (request, response) { + if (request.url === '/.well-known/apple-app-site-association') { + // eslint-disable-next-line + fs.readFile( + path.resolve(__dirname, 'apple-app-site-association'), + function (_error, content) { + response.writeHead(200) + response.end(content, 'utf-8') + } + ) + } else { + response.writeHead(404) + response.end() + } + }) + .listen(port) + +console.log(`Server running at http://127.0.0.1:${port}/`) diff --git a/plugins/deep-link/examples/app/src-tauri/src/lib.rs b/plugins/deep-link/examples/app/src-tauri/src/lib.rs new file mode 100644 index 0000000..f85527d --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/src/lib.rs @@ -0,0 +1,49 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri_plugin_deep_link::DeepLinkExt; + +// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +#[tauri::command] +fn greet(name: &str) -> String { + format!("Hello, {name}! You've been greeted from Rust!") +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[allow(unused_mut)] + let mut builder = tauri::Builder::default(); + + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| { + println!("single instance triggered: {argv:?}"); + })); + } + + builder + .plugin(tauri_plugin_deep_link::init()) + .plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + ) + .setup(|app| { + // ensure deep links are registered on the system + // this is useful because AppImages requires additional setup to be available in the system + // and calling register() makes the deep links immediately available - without any user input + // additionally, we manually register on Windows on debug builds for development + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + app.deep_link().register_all()?; + + app.deep_link().on_open_url(|event| { + dbg!(event.urls()); + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![greet]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/plugins/deep-link/examples/app/src-tauri/src/main.rs b/plugins/deep-link/examples/app/src-tauri/src/main.rs new file mode 100644 index 0000000..799fad7 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/src/main.rs @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + #[cfg(desktop)] + deep_link_example::run(); +} diff --git a/plugins/deep-link/examples/app/src-tauri/tauri.conf.json b/plugins/deep-link/examples/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..61f7394 --- /dev/null +++ b/plugins/deep-link/examples/app/src-tauri/tauri.conf.json @@ -0,0 +1,59 @@ +{ + "productName": "deep-link-example", + "version": "0.1.0", + "identifier": "com.tauri.deep-link-example", + "build": { + "devUrl": "http://localhost:1420", + "frontendDist": "../dist", + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build" + }, + "app": { + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "height": 600, + "resizable": true, + "title": "tauri-app", + "width": 800 + } + ] + }, + "plugins": { + "whatever": "helloworld", + "sec": { + "hello": "world" + }, + "deep-link": { + "mobile": [ + { + "host": "fabianlars.de", + "pathPrefix": ["/intent"] + }, + { + "host": "tauri.app" + }, + { + "scheme": ["taurideeplink"] + } + ], + "desktop": { + "schemes": ["fabianlars", "my-tauri-app"] + } + } + }, + "bundle": { + "active": true, + "category": "DeveloperTool", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/plugins/deep-link/examples/app/src/assets/tauri.svg b/plugins/deep-link/examples/app/src/assets/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/plugins/deep-link/examples/app/src/assets/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/deep-link/examples/app/src/assets/typescript.svg b/plugins/deep-link/examples/app/src/assets/typescript.svg new file mode 100644 index 0000000..30a5edd --- /dev/null +++ b/plugins/deep-link/examples/app/src/assets/typescript.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/plugins/deep-link/examples/app/src/assets/vite.svg b/plugins/deep-link/examples/app/src/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/plugins/deep-link/examples/app/src/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/deep-link/examples/app/src/main.ts b/plugins/deep-link/examples/app/src/main.ts new file mode 100644 index 0000000..550e0aa --- /dev/null +++ b/plugins/deep-link/examples/app/src/main.ts @@ -0,0 +1,38 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { + onOpenUrl, + getCurrent as getCurrentDeepLinkUrls +} from '@tauri-apps/plugin-deep-link' + +function handler(urls: string[]) { + console.log(urls) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const updateIntentEl = document.querySelector('#event-intent')! + updateIntentEl.textContent = JSON.stringify(urls) +} + +window.addEventListener('DOMContentLoaded', () => { + onOpenUrl(handler) + + document.querySelector('#intent-form')?.addEventListener('submit', (e) => { + e.preventDefault() + getCurrentDeepLinkUrls() + .then((res) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const updateIntentEl = document.querySelector('#update-intent')! + updateIntentEl.textContent = res ? JSON.stringify(res) : 'none' + }) + .catch(console.error) + }) + + getCurrentDeepLinkUrls() + .then((res) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const initialIntentEl = document.querySelector('#initial-intent')! + initialIntentEl.textContent = res ? JSON.stringify(res) : 'none' + }) + .catch(console.error) +}) diff --git a/plugins/deep-link/examples/app/src/styles.css b/plugins/deep-link/examples/app/src/styles.css new file mode 100644 index 0000000..f7de85b --- /dev/null +++ b/plugins/deep-link/examples/app/src/styles.css @@ -0,0 +1,109 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color: #0f0f0f; + background-color: #f6f6f6; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +.container { + margin: 0; + padding-top: 10vh; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: 0.75s; +} + +.logo.tauri:hover { + filter: drop-shadow(0 0 2em #24c8db); +} + +.row { + display: flex; + justify-content: center; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +h1 { + text-align: center; +} + +input, +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + color: #0f0f0f; + background-color: #ffffff; + transition: border-color 0.25s; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +} + +button { + cursor: pointer; +} + +button:hover { + border-color: #396cd8; +} +button:active { + border-color: #396cd8; + background-color: #e8e8e8; +} + +input, +button { + outline: none; +} + +#greet-input { + margin-right: 5px; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #f6f6f6; + background-color: #2f2f2f; + } + + a:hover { + color: #24c8db; + } + + input, + button { + color: #ffffff; + background-color: #0f0f0f98; + } + button:active { + background-color: #0f0f0f69; + } +} diff --git a/plugins/deep-link/examples/app/tsconfig.json b/plugins/deep-link/examples/app/tsconfig.json new file mode 100644 index 0000000..c0f337a --- /dev/null +++ b/plugins/deep-link/examples/app/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "bundler", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "types": ["vite/client"], + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true + }, + "include": ["./src"] +} diff --git a/plugins/deep-link/examples/app/vite.config.ts b/plugins/deep-link/examples/app/vite.config.ts new file mode 100644 index 0000000..b54dc99 --- /dev/null +++ b/plugins/deep-link/examples/app/vite.config.ts @@ -0,0 +1,38 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { defineConfig } from 'vite' + +const host = process.env.TAURI_DEV_HOST + +// https://vitejs.dev/config/ +export default defineConfig({ + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // prevent vite from obscuring rust errors + clearScreen: false, + // tauri expects a fixed port, fail if that port is not available + server: { + host: host || false, + port: 1420, + hmr: host + ? { + protocol: 'ws', + host, + port: 1421 + } + : undefined, + strictPort: true + }, + // to make use of `TAURI_DEBUG` and other env variables + // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand + envPrefix: ['VITE_', 'TAURI_'], + build: { + // Tauri supports es2021 + target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', + // don't minify for debug builds + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + // produce sourcemaps for debug builds + sourcemap: !!process.env.TAURI_DEBUG + } +}) diff --git a/plugins/deep-link/guest-js/index.ts b/plugins/deep-link/guest-js/index.ts new file mode 100644 index 0000000..41b2fe8 --- /dev/null +++ b/plugins/deep-link/guest-js/index.ts @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Set your Tauri application as the default handler for a URL, or check which URL(s) it was opened with. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' +import { type UnlistenFn, listen } from '@tauri-apps/api/event' + +/** + * Get the current URLs that triggered the deep link. Use this on app load to check whether your app was started via a deep link. + * + * @example + * ```typescript + * import { getCurrent } from '@tauri-apps/plugin-deep-link'; + * const urls = await getCurrent(); + * ``` + * + * #### Platform-specific + * + * - **Windows / Linux:** This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values. + * Note that you must manually check the arguments when registering deep link schemes dynamically with {@link register}. + * Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect. + * + * @returns A promise resolving to the list of URLs that triggered the deep link, or `null` if the app was not started via a deep link. + * @since 2.0.0 + */ +export async function getCurrent(): Promise { + return await invoke('plugin:deep-link|get_current') +} + +/** + * Register the app as the default handler for the specified protocol. + * + * @param protocol The name of the protocol without `://`. For example, if you want your app to handle `tauri://` links, call this method with `tauri` as the protocol. + * + * @example + * ```typescript + * import { register } from '@tauri-apps/plugin-deep-link'; + * await register("my-scheme"); + * ``` + * + * #### Platform-specific + * + * - **macOS / Android / iOS:** Unsupported. + * + * @returns A promise that resolves once the protocol has been registered. + * @since 2.0.0 + */ +export async function register(protocol: string): Promise { + return await invoke('plugin:deep-link|register', { protocol }) +} + +/** + * Unregister the app as the default handler for the specified protocol. + * + * @param protocol The name of the protocol without `://`. + * + * @example + * ```typescript + * import { unregister } from '@tauri-apps/plugin-deep-link'; + * await unregister("my-scheme"); + * ``` + * + * #### Platform-specific + * + * - **Windows:** Requires admin rights if the protocol is registered on the local machine (this can happen when registered from the NSIS installer when the install mode is set to both or per machine). + * - **Linux:** Can only unregister the scheme if it was initially registered with {@link register}. May not work on older distros. + * - **macOS / Android / iOS:** Unsupported. + * + * @returns A promise that resolves once the protocol has been unregistered. + * @since 2.0.0 + */ +export async function unregister(protocol: string): Promise { + return await invoke('plugin:deep-link|unregister', { protocol }) +} + +/** + * Check whether the app is the default handler for the specified protocol. + * + * @param protocol The name of the protocol without `://`. + * + * @example + * ```typescript + * import { isRegistered } from '@tauri-apps/plugin-deep-link'; + * await isRegistered("my-scheme"); + * ``` + * + * #### Platform-specific + * + * - **macOS / Android / iOS:** Unsupported. + * + * @returns A promise resolving to `true` if the app is the default handler for the protocol, `false` otherwise. + * @since 2.0.0 + */ +export async function isRegistered(protocol: string): Promise { + return await invoke('plugin:deep-link|is_registered', { protocol }) +} + +/** + * Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running. Use {@link getCurrent} on app load to check whether your app was started via a deep link. + * + * @param handler The function to call with the list of URLs the app was requested to open, every time this happens while the app is running. + * + * @example + * ```typescript + * import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; + * await onOpenUrl((urls) => { console.log(urls) }); + * ``` + * + * #### Platform-specific + * + * - **Windows / Linux:** Unsupported without the single-instance plugin. The OS will spawn a new app instance passing the URL as a CLI argument. + * + * @returns A promise resolving to a function that unregisters the event listener. + * @since 2.0.0 + */ +export async function onOpenUrl( + handler: (urls: string[]) => void +): Promise { + return await listen('deep-link://new-url', (event) => { + handler(event.payload) + }) +} diff --git a/plugins/deep-link/package.json b/plugins/deep-link/package.json new file mode 100644 index 0000000..3d21b7e --- /dev/null +++ b/plugins/deep-link/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tauri-apps/plugin-deep-link", + "version": "2.4.10", + "description": "Set your Tauri application as the default handler for an URL", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } +} diff --git a/plugins/deep-link/permissions/autogenerated/commands/get_current.toml b/plugins/deep-link/permissions/autogenerated/commands/get_current.toml new file mode 100644 index 0000000..e729de1 --- /dev/null +++ b/plugins/deep-link/permissions/autogenerated/commands/get_current.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-current" +description = "Enables the get_current command without any pre-configured scope." +commands.allow = ["get_current"] + +[[permission]] +identifier = "deny-get-current" +description = "Denies the get_current command without any pre-configured scope." +commands.deny = ["get_current"] diff --git a/plugins/deep-link/permissions/autogenerated/commands/is_registered.toml b/plugins/deep-link/permissions/autogenerated/commands/is_registered.toml new file mode 100644 index 0000000..2dd73ac --- /dev/null +++ b/plugins/deep-link/permissions/autogenerated/commands/is_registered.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-registered" +description = "Enables the is_registered command without any pre-configured scope." +commands.allow = ["is_registered"] + +[[permission]] +identifier = "deny-is-registered" +description = "Denies the is_registered command without any pre-configured scope." +commands.deny = ["is_registered"] diff --git a/plugins/deep-link/permissions/autogenerated/commands/register.toml b/plugins/deep-link/permissions/autogenerated/commands/register.toml new file mode 100644 index 0000000..4eec17d --- /dev/null +++ b/plugins/deep-link/permissions/autogenerated/commands/register.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register" +description = "Enables the register command without any pre-configured scope." +commands.allow = ["register"] + +[[permission]] +identifier = "deny-register" +description = "Denies the register command without any pre-configured scope." +commands.deny = ["register"] diff --git a/plugins/deep-link/permissions/autogenerated/commands/unregister.toml b/plugins/deep-link/permissions/autogenerated/commands/unregister.toml new file mode 100644 index 0000000..5d33c97 --- /dev/null +++ b/plugins/deep-link/permissions/autogenerated/commands/unregister.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unregister" +description = "Enables the unregister command without any pre-configured scope." +commands.allow = ["unregister"] + +[[permission]] +identifier = "deny-unregister" +description = "Denies the unregister command without any pre-configured scope." +commands.deny = ["unregister"] diff --git a/plugins/deep-link/permissions/autogenerated/reference.md b/plugins/deep-link/permissions/autogenerated/reference.md new file mode 100644 index 0000000..a8ef187 --- /dev/null +++ b/plugins/deep-link/permissions/autogenerated/reference.md @@ -0,0 +1,121 @@ +## Default Permission + +Allows reading the opened deep link via the get_current command + +#### This default permission set includes the following: + +- `allow-get-current` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`deep-link:allow-get-current` + + + +Enables the get_current command without any pre-configured scope. + +
+ +`deep-link:deny-get-current` + + + +Denies the get_current command without any pre-configured scope. + +
+ +`deep-link:allow-is-registered` + + + +Enables the is_registered command without any pre-configured scope. + +
+ +`deep-link:deny-is-registered` + + + +Denies the is_registered command without any pre-configured scope. + +
+ +`deep-link:allow-register` + + + +Enables the register command without any pre-configured scope. + +
+ +`deep-link:deny-register` + + + +Denies the register command without any pre-configured scope. + +
+ +`deep-link:allow-unregister` + + + +Enables the unregister command without any pre-configured scope. + +
+ +`deep-link:deny-unregister` + + + +Denies the unregister command without any pre-configured scope. + +
diff --git a/plugins/deep-link/permissions/default.toml b/plugins/deep-link/permissions/default.toml new file mode 100644 index 0000000..0c3cf2c --- /dev/null +++ b/plugins/deep-link/permissions/default.toml @@ -0,0 +1,4 @@ +"$schema" = "schemas/schema.json" +[default] +description = "Allows reading the opened deep link via the get_current command" +permissions = ["allow-get-current"] diff --git a/plugins/deep-link/permissions/schemas/schema.json b/plugins/deep-link/permissions/schemas/schema.json new file mode 100644 index 0000000..c9fc2ce --- /dev/null +++ b/plugins/deep-link/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the get_current command without any pre-configured scope.", + "type": "string", + "const": "allow-get-current", + "markdownDescription": "Enables the get_current command without any pre-configured scope." + }, + { + "description": "Denies the get_current command without any pre-configured scope.", + "type": "string", + "const": "deny-get-current", + "markdownDescription": "Denies the get_current command without any pre-configured scope." + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`", + "type": "string", + "const": "default", + "markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/deep-link/rollup.config.js b/plugins/deep-link/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/deep-link/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/deep-link/src/commands.rs b/plugins/deep-link/src/commands.rs new file mode 100644 index 0000000..078adfb --- /dev/null +++ b/plugins/deep-link/src/commands.rs @@ -0,0 +1,46 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{command, AppHandle, Runtime, State, Window}; + +use crate::{DeepLink, Result}; + +#[command] +pub(crate) async fn get_current( + _app: AppHandle, + _window: Window, + deep_link: State<'_, DeepLink>, +) -> Result>> { + deep_link.get_current() +} + +#[command] +pub(crate) async fn register( + _app: AppHandle, + _window: Window, + deep_link: State<'_, DeepLink>, + protocol: String, +) -> Result<()> { + deep_link.register(protocol) +} + +#[command] +pub(crate) async fn unregister( + _app: AppHandle, + _window: Window, + deep_link: State<'_, DeepLink>, + protocol: String, +) -> Result<()> { + deep_link.unregister(protocol) +} + +#[command] +pub(crate) async fn is_registered( + _app: AppHandle, + _window: Window, + deep_link: State<'_, DeepLink>, + protocol: String, +) -> Result { + deep_link.is_registered(protocol) +} diff --git a/plugins/deep-link/src/config.rs b/plugins/deep-link/src/config.rs new file mode 100644 index 0000000..49de68e --- /dev/null +++ b/plugins/deep-link/src/config.rs @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// This module is also imported in build.rs! + +use serde::{Deserialize, Deserializer}; +use tauri_utils::config::DeepLinkProtocol; + +#[derive(Deserialize, Clone)] +pub struct AssociatedDomain { + #[serde(default = "default_schemes")] + pub scheme: Vec, + #[serde(default, deserialize_with = "deserialize_associated_host")] + pub host: Option, // Optional custom uri schemes dont NEED a host (may have one still), but required for https/http schemes + #[serde(default)] + pub path: Vec, + #[serde(default, alias = "path-pattern", rename = "pathPattern")] + pub path_pattern: Vec, + #[serde(default, alias = "path-prefix", rename = "pathPrefix")] + pub path_prefix: Vec, + #[serde(default, alias = "path-suffix", rename = "pathSuffix")] + pub path_suffix: Vec, + #[serde(default, alias = "app-link", rename = "appLink")] + pub app_link: Option, +} + +impl AssociatedDomain { + /// Returns true if the domain uses http or https scheme. + pub fn is_web_link(&self) -> bool { + self.scheme.iter().any(|s| s == "https" || s == "http") + } + + /// Returns true if the domain uses http or https scheme and has proper host configuration. + pub fn is_app_link(&self) -> bool { + self.app_link + .unwrap_or_else(|| self.is_web_link() && self.host.is_some()) + } + + pub fn validate(&self) -> Result<(), String> { + // Rule 1: All web links require a host. + if self.is_web_link() && self.host.is_none() { + return Err("Web link requires a host".into()); + } + + // Rule 2: If it's an App Link, ensure http(s) and host. + if self.is_app_link() { + if !self.is_web_link() { + return Err("AppLink must be a valid web link (https/http + host)".into()); + } + if self.scheme.iter().any(|s| s == "http") && !self.scheme.iter().any(|s| s == "https") + { + eprintln!("Warning: AppLink uses only 'http' — allowed on Android but not secure for production."); + } + } + + Ok(()) + } +} + +// TODO: Consider removing this in v3 +fn default_schemes() -> Vec { + vec!["https".to_string(), "http".to_string()] +} + +fn deserialize_associated_host<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + if let Some(ref host) = opt { + if let Some((scheme, _)) = host.split_once("://") { + return Err(serde::de::Error::custom(format!( + "host `{host}` cannot start with a scheme, please remove the `{scheme}://` prefix" + ))); + } + } + Ok(opt) +} + +#[derive(Deserialize, Clone)] +pub struct Config { + /// Mobile requires `https://` urls. + #[serde(default)] + pub mobile: Vec, + /// Desktop requires urls starting with `://`. + /// These urls are also active in dev mode on Android. + #[allow(unused)] // Used in tauri-bundler + #[serde(default)] + pub desktop: DesktopProtocol, +} + +#[derive(Deserialize, Clone)] +#[serde(untagged)] +#[allow(unused)] // Used in tauri-bundler +pub enum DesktopProtocol { + One(DeepLinkProtocol), + List(Vec), +} + +impl Default for DesktopProtocol { + fn default() -> Self { + Self::List(Vec::new()) + } +} + +impl DesktopProtocol { + #[allow(dead_code)] + pub fn contains_scheme(&self, scheme: &String) -> bool { + match self { + Self::One(protocol) => protocol.schemes.contains(scheme), + Self::List(protocols) => protocols + .iter() + .any(|protocol| protocol.schemes.contains(scheme)), + } + } + + #[allow(dead_code)] + pub fn schemes(&self) -> Vec { + match self { + Self::One(protocol) => protocol.schemes.clone(), + Self::List(protocols) => protocols + .iter() + .flat_map(|protocol| protocol.schemes.clone()) + .collect(), + } + } +} diff --git a/plugins/deep-link/src/error.rs b/plugins/deep-link/src/error.rs new file mode 100644 index 0000000..7832995 --- /dev/null +++ b/plugins/deep-link/src/error.rs @@ -0,0 +1,61 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. +pub type Result = std::result::Result; + +/// The error type for this plugin. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The requested operation (usually registering or unregistering a protocol scheme at + /// runtime) is not supported on the current platform. + #[error("unsupported platform")] + UnsupportedPlatform, + /// Transparent wrapper around an [`std::io::Error`]. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Transparent wrapper around a [`tauri::Error`]. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// Transparent wrapper around a [`windows_result::Error`]. Only used on Windows. + #[cfg(target_os = "windows")] + #[error(transparent)] + Windows(#[from] windows_result::Error), + /// Transparent wrapper around an [`ini::Error`], returned when reading or writing the + /// `.desktop` file used to register a protocol scheme. Only used on Linux. + #[cfg(target_os = "linux")] + #[error(transparent)] + Ini(#[from] ini::Error), + /// Transparent wrapper around an [`ini::ParseError`], returned when parsing the + /// `.desktop` file used to register a protocol scheme. Only used on Linux. + #[cfg(target_os = "linux")] + #[error(transparent)] + ParseIni(#[from] ini::ParseError), + /// Transparent wrapper around a [`tauri::plugin::mobile::PluginInvokeError`], returned + /// when the underlying mobile plugin invocation fails. Only used on mobile. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +// TODO(v3): change this into an error in v3, +// see . +#[inline] +#[cfg(target_os = "linux")] +pub(crate) fn inspect_command_error<'a>(command: &'a str) -> impl Fn(&std::io::Error) + 'a { + move |e| { + tracing::error!("Failed to run OS command `{command}`: {e}"); + } +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/deep-link/src/lib.rs b/plugins/deep-link/src/lib.rs new file mode 100644 index 0000000..9796060 --- /dev/null +++ b/plugins/deep-link/src/lib.rs @@ -0,0 +1,601 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Set your Tauri application as the default handler for a URL, or check which URL(s) it was +//! opened with. +//! +//! On Windows and Linux, protocol schemes can additionally be registered and unregistered at +//! runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS +//! the schemes declared in the Tauri configuration are registered at build time instead, so +//! calling those methods returns [`Error::UnsupportedPlatform`]. + +use tauri::{ + plugin::{Builder, PluginApi, TauriPlugin}, + AppHandle, EventId, Listener, Manager, Runtime, +}; + +mod commands; +mod config; +mod error; + +pub use error::{Error, Result}; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.deep_link"; + +fn init_deep_link( + app: &AppHandle, + api: PluginApi>, +) -> crate::Result> { + #[cfg(target_os = "android")] + { + let _api = api; + + use tauri::{ + ipc::{Channel, InvokeResponseBody}, + Emitter, + }; + + let handle = _api.register_android_plugin(PLUGIN_IDENTIFIER, "DeepLinkPlugin")?; + + #[derive(serde::Deserialize)] + struct Event { + url: String, + } + + let app_handle = app.clone(); + handle.run_mobile_plugin::<()>( + "setEventHandler", + imp::EventHandler { + handler: Channel::new(move |event| { + let url = match event { + InvokeResponseBody::Json(payload) => { + serde_json::from_str::(&payload) + .ok() + .map(|payload| payload.url) + } + _ => None, + }; + + let _ = app_handle.emit("deep-link://new-url", vec![url]); + + Ok(()) + }), + }, + )?; + + return Ok(DeepLink { + app: app.clone(), + plugin_handle: handle, + }); + } + + #[cfg(target_os = "ios")] + return Ok(DeepLink { + app: app.clone(), + current: Default::default(), + config: api.config().clone(), + }); + + #[cfg(desktop)] + { + let args = std::env::args(); + let deep_link = DeepLink { + app: app.clone(), + current: Default::default(), + config: api.config().clone(), + }; + deep_link.handle_cli_arguments(args); + + Ok(deep_link) + } +} + +#[cfg(target_os = "android")] +mod imp { + use tauri::{ipc::Channel, plugin::PluginHandle, AppHandle, Runtime}; + + use serde::{Deserialize, Serialize}; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct EventHandler { + pub handler: Channel, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct GetCurrentResponse { + pub url: Option, + } + + /// Access to the deep-link APIs. + pub struct DeepLink { + pub(crate) app: AppHandle, + pub(crate) plugin_handle: PluginHandle, + } + + impl DeepLink { + /// Get the current URLs that triggered the deep link. Use this on app load to check whether your app was started via a deep link. + /// + /// ## Platform-specific: + /// + /// - **Windows / Linux**: This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values. + /// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`]. + /// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect. + pub fn get_current(&self) -> crate::Result>> { + self.plugin_handle + .run_mobile_plugin::("getCurrent", ()) + .map(|v| v.url.map(|url| vec![url])) + .map_err(Into::into) + } + + /// Register the app as the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. For example, if you want your app to handle `tauri://` links, call this method with `tauri` as the protocol. + /// + /// ## Platform-specific: + /// + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn register>(&self, _protocol: S) -> crate::Result<()> { + Err(crate::Error::UnsupportedPlatform) + } + + /// Unregister the app as the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. + /// + /// ## Platform-specific: + /// + /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Needs the `update-desktop-database` command available on the system. May not work on older distros. + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn unregister>(&self, _protocol: S) -> crate::Result<()> { + Err(crate::Error::UnsupportedPlatform) + } + + /// Check whether the app is the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. + /// + /// ## Platform-specific: + /// + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn is_registered>(&self, _protocol: S) -> crate::Result { + Err(crate::Error::UnsupportedPlatform) + } + } +} + +#[cfg(not(target_os = "android"))] +mod imp { + use std::sync::Mutex; + #[cfg(target_os = "linux")] + use std::{ + fs::{create_dir_all, File}, + io::Write, + process::Command, + }; + #[cfg(target_os = "linux")] + use tauri::Manager; + use tauri::{AppHandle, Runtime}; + #[cfg(windows)] + use windows_registry::{CLASSES_ROOT, CURRENT_USER, LOCAL_MACHINE}; + + /// Access to the deep-link APIs. + pub struct DeepLink { + pub(crate) app: AppHandle, + pub(crate) current: Mutex>>, + pub(crate) config: Option, + } + + impl DeepLink { + /// Checks if the provided list of arguments (which should match [`std::env::args`]) + /// contains a deep link argument (for Linux and Windows). + /// + /// On Linux and Windows the deep links trigger a new app instance with the deep link URL as its only argument. + /// + /// This function does what it can to verify if the argument is actually a deep link, though it could also be a regular CLI argument. + /// To enhance its checks, we only match deep links against the schemes defined in the Tauri configuration + /// i.e. dynamic schemes WON'T be processed. + /// + /// This function updates the [`Self::get_current`] value and emits a `deep-link://new-url` event. + #[cfg(desktop)] + pub fn handle_cli_arguments, I: Iterator>(&self, mut args: I) { + use tauri::Emitter; + + let Some(config) = &self.config else { + return; + }; + + if cfg!(windows) || cfg!(target_os = "linux") { + args.next(); // bin name + let arg = args.next(); + + let maybe_deep_link = args.next().is_none(); // single argument + if !maybe_deep_link { + return; + } + + if let Some(url) = arg.and_then(|arg| arg.as_ref().parse::().ok()) { + if config.desktop.contains_scheme(&url.scheme().to_string()) { + let mut current = self.current.lock().unwrap(); + current.replace(vec![url.clone()]); + let _ = self.app.emit("deep-link://new-url", vec![url]); + } else if cfg!(debug_assertions) { + tracing::warn!("argument {url} does not match any configured deep link scheme; skipping it"); + } + } + } + } + + /// Get the current URLs that triggered the deep link. Use this on app load to check whether your app was started via a deep link. + /// + /// ## Platform-specific: + /// + /// - **Windows / Linux**: This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values. + /// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`]. + /// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect. + pub fn get_current(&self) -> crate::Result>> { + return Ok(self.current.lock().unwrap().clone()); + } + + /// Registers all schemes defined in the configuration file. + /// + /// This is useful to ensure the schemes are registered even if the user did not install the app properly + /// (e.g. an AppImage that was not properly registered with an AppImage launcher). + pub fn register_all(&self) -> crate::Result<()> { + let Some(config) = &self.config else { + return Ok(()); + }; + + for scheme in config.desktop.schemes() { + self.register(scheme)?; + } + + Ok(()) + } + + /// Register the app as the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. For example, if you want your app to handle `tauri://` links, call this method with `tauri` as the protocol. + /// + /// ## Platform-specific: + /// + /// - **Linux**: Needs the `xdg-mime` and `update-desktop-database` commands available on the system. + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn register>(&self, _protocol: S) -> crate::Result<()> { + #[cfg(windows)] + { + let protocol = _protocol.as_ref(); + let key_base = format!("Software\\Classes\\{protocol}"); + + let exe = dunce::simplified(&tauri::utils::platform::current_exe()?) + .display() + .to_string(); + + let key_reg = CURRENT_USER.create(&key_base)?; + key_reg.set_string("", format!("URL:{} protocol", self.app.config().identifier))?; + key_reg.set_string("URL Protocol", "")?; + + let icon_reg = CURRENT_USER.create(format!("{key_base}\\DefaultIcon"))?; + icon_reg.set_string("", format!("{exe},0"))?; + + let cmd_reg = CURRENT_USER.create(format!("{key_base}\\shell\\open\\command"))?; + + cmd_reg.set_string("", format!("\"{exe}\" \"%1\""))?; + + Ok(()) + } + + #[cfg(target_os = "linux")] + { + let bin = tauri::utils::platform::current_exe()?; + let file_name = format!( + "{}-handler.desktop", + bin.file_name().unwrap().to_string_lossy() + ); + let appimage = self.app.env().appimage; + let exec = appimage + .clone() + .unwrap_or_else(|| bin.into_os_string()) + .to_string_lossy() + .to_string(); + let qualified_exec = format!("\"{}\" %u", exec); + + let target = self.app.path().data_dir()?.join("applications"); + + create_dir_all(&target)?; + + let target_file = target.join(&file_name); + + let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref()); + + if let Ok(mut desktop_file) = ini::Ini::load_from_file(&target_file) { + if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { + let old_mimes = section.remove("MimeType").unwrap_or_default(); + let mut change = false; + + // if the mime type is not present, append it to the list + if !old_mimes.split(';').any(|mime| mime == mime_type) { + section.append("MimeType", format!("{mime_type};{old_mimes}")); + change = true; + } else { + section.insert("MimeType".to_string(), old_mimes); + } + + // if the exec command doesnt match, update to the new one + let old_exec = section.remove("Exec").unwrap_or_default(); + if old_exec != qualified_exec { + section.append("Exec", qualified_exec); + change = true; + } else { + section.insert("Exec".to_string(), old_exec.to_string()); + } + + // if any property has changed, rewrite the .desktop file + if change { + desktop_file.write_to_file(&target_file)?; + } + } + } else { + let mut file = File::create(target_file)?; + file.write_all( + format!( + include_str!("template.desktop"), + name = self + .app + .config() + .product_name + .clone() + .unwrap_or_else(|| file_name.clone()), + qualified_exec = qualified_exec, + mime_type = mime_type + ) + .as_bytes(), + )?; + } + + Command::new("update-desktop-database") + .arg(target) + .status() + .inspect_err(crate::error::inspect_command_error( + "update-desktop-database", + ))?; + + Command::new("xdg-mime") + .args(["default", &file_name, mime_type.as_str()]) + .status() + .inspect_err(crate::error::inspect_command_error("xdg-mime"))?; + + Ok(()) + } + + #[cfg(not(any(windows, target_os = "linux")))] + Err(crate::Error::UnsupportedPlatform) + } + + /// Unregister the app as the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. + /// + /// ## Platform-specific: + /// + /// - **Windows**: Requires admin rights if the protocol is registered on local machine + /// (this can happen when registered from the NSIS installer when the install mode is set to both or per machine) + /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Refreshes the desktop database with the `update-desktop-database` command; without it, [`is_registered`](`Self::is_registered`) may keep returning `true`. May not work on older distros. + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn unregister>(&self, _protocol: S) -> crate::Result<()> { + #[cfg(windows)] + { + let protocol = _protocol.as_ref(); + let path = format!("Software\\Classes\\{protocol}"); + if LOCAL_MACHINE.open(&path).is_ok() { + LOCAL_MACHINE.remove_tree(&path)?; + } + if CURRENT_USER.open(&path).is_ok() { + CURRENT_USER.remove_tree(&path)?; + } + Ok(()) + } + + #[cfg(target_os = "linux")] + { + let file_name = format!( + "{}-handler.desktop", + tauri::utils::platform::current_exe()? + .file_name() + .unwrap() + .to_string_lossy() + ); + let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref()); + + // stop being the default handler + let mimeapps_path = self.app.path().config_dir()?.join("mimeapps.list"); + if mimeapps_path.exists() { + let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path)?; + if let Some(section) = mimeapps.section_mut(Some("Default Applications")) { + if section.get(&mime_type).unwrap_or_default() == file_name { + section.remove(&mime_type); + } + } + mimeapps.write_to_file(&mimeapps_path)?; + } + + // Stop declaring the scheme in the handler's `.desktop` file too: the desktop + // database indexes it, and with no default set `xdg-mime` falls back to that + // index, so the app would otherwise still be the handler. + let applications = self.app.path().data_dir()?.join("applications"); + let desktop_file_path = applications.join(&file_name); + // Only the `MimeType` key is touched: the file may carry other changes. + if let Ok(mut desktop_file) = ini::Ini::load_from_file(&desktop_file_path) { + if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { + let mime_types = section + .get("MimeType") + .unwrap_or_default() + .split(';') + .filter(|mime| !mime.is_empty() && *mime != mime_type) + .map(ToString::to_string) + .collect::>(); + if mime_types.is_empty() { + section.remove("MimeType"); + } else { + section.insert("MimeType", mime_types.join(";")); + } + } + desktop_file.write_to_file(&desktop_file_path)?; + + // Without the refreshed index `xdg-mime` may keep reporting the app as the + // handler, but the scheme is unregistered as far as the app can tell, so a + // missing command is not an error. + if let Err(e) = Command::new("update-desktop-database") + .arg(&applications) + .status() + { + tracing::warn!( + "Failed to run OS command `update-desktop-database`, the desktop database may still list the app as the `{mime_type}` handler: {e}" + ); + } + } + + Ok(()) + } + + #[cfg(not(any(windows, target_os = "linux")))] + Err(crate::Error::UnsupportedPlatform) + } + + /// Check whether the app is the default handler for the specified protocol. + /// + /// - `protocol`: The name of the protocol without `://`. + /// + /// ## Platform-specific: + /// + /// - **Linux**: Needs the `xdg-mime` command available on the system. + /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). + pub fn is_registered>(&self, _protocol: S) -> crate::Result { + #[cfg(windows)] + { + let protocol = _protocol.as_ref(); + let Ok(cmd_reg) = CLASSES_ROOT.open(format!("{protocol}\\shell\\open\\command")) + else { + return Ok(false); + }; + + let registered_cmd = cmd_reg.get_string("")?; + + let exe = dunce::simplified(&tauri::utils::platform::current_exe()?) + .display() + .to_string(); + + Ok(registered_cmd == format!("\"{exe}\" \"%1\"")) + } + #[cfg(target_os = "linux")] + { + let file_name = format!( + "{}-handler.desktop", + tauri::utils::platform::current_exe()? + .file_name() + .unwrap() + .to_string_lossy() + ); + + let output = Command::new("xdg-mime") + .args([ + "query", + "default", + &format!("x-scheme-handler/{}", _protocol.as_ref()), + ]) + .output() + .inspect_err(crate::error::inspect_command_error("xdg-mime"))?; + + Ok(String::from_utf8_lossy(&output.stdout).contains(&file_name)) + } + + #[cfg(not(any(windows, target_os = "linux")))] + Err(crate::Error::UnsupportedPlatform) + } + } +} + +pub use imp::DeepLink; +use url::Url; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the deep-link APIs. +pub trait DeepLinkExt { + /// Returns a reference to the [`DeepLink`] API. + fn deep_link(&self) -> &DeepLink; +} + +impl> crate::DeepLinkExt for T { + fn deep_link(&self) -> &DeepLink { + self.state::>().inner() + } +} + +/// Event that is triggered when the app was requested to open a new URL. +/// +/// Typed [`tauri::Event`]. +pub struct OpenUrlEvent { + id: EventId, + urls: Vec, +} + +impl OpenUrlEvent { + /// The event ID which can be used to stop listening to the event via [`tauri::Listener::unlisten`]. + pub fn id(&self) -> EventId { + self.id + } + + /// The event URLs. + pub fn urls(self) -> Vec { + self.urls + } +} + +impl DeepLink { + /// Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running. + /// + /// Use `get_current` on app load to check whether your app was started via a deep link. + pub fn on_open_url(&self, f: F) -> EventId { + let event_id = self.app.listen("deep-link://new-url", move |event| { + if let Ok(urls) = serde_json::from_str(event.payload()) { + f(OpenUrlEvent { + id: event.id(), + urls, + }) + } + }); + + event_id + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin> { + Builder::new("deep-link") + .invoke_handler(tauri::generate_handler![ + commands::get_current, + commands::register, + commands::unregister, + commands::is_registered + ]) + .setup(|app, api| { + app.manage(init_deep_link(app, api)?); + Ok(()) + }) + .on_event(|_app, _event| { + #[cfg(any(target_os = "macos", target_os = "ios"))] + if let tauri::RunEvent::Opened { urls } = _event { + use tauri::Emitter; + + let _ = _app.emit("deep-link://new-url", urls); + _app.state::>() + .current + .lock() + .unwrap() + .replace(urls.clone()); + } + }) + .build() +} diff --git a/plugins/deep-link/src/template.desktop b/plugins/deep-link/src/template.desktop new file mode 100644 index 0000000..068beb1 --- /dev/null +++ b/plugins/deep-link/src/template.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name={name} +Exec={qualified_exec} +Terminal=false +MimeType={mime_type} +NoDisplay=true diff --git a/plugins/deep-link/tsconfig.json b/plugins/deep-link/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/deep-link/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/dialog/CHANGELOG.md b/plugins/dialog/CHANGELOG.md new file mode 100644 index 0000000..4af3e7d --- /dev/null +++ b/plugins/dialog/CHANGELOG.md @@ -0,0 +1,341 @@ +# Changelog + +## [2.7.3] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`cc9ec9b4`](https://github.com/tauri-apps/plugins-workspace/commit/cc9ec9b4ad2f9ec9bd57c3503ded1bd94d092c48) ([#3519](https://github.com/tauri-apps/plugins-workspace/pull/3519) by [@purvsinojiya-inventyv](https://github.com/tauri-apps/plugins-workspace/../../purvsinojiya-inventyv)) Fix Android save dialog MIME type when a single MIME type is provided. +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +### Dependencies + +- Upgraded to `fs@2.5.2` + +## \[2.7.2] + +- [`40ae0a7f`](https://github.com/tauri-apps/plugins-workspace/commit/40ae0a7fa0ecc9b0cdbd952297f7a17f37308229) ([#3491](https://github.com/tauri-apps/plugins-workspace/pull/3491) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Use `com.google.android.material.dialog.MaterialAlertDialogBuilder` instead of `AlertDialog` so the dialog follows the app's theme + +## \[2.7.1] + +### Dependencies + +- Upgraded to `fs-js@2.5.1` + +## \[2.7.0] + +- [`24154472`](https://github.com/tauri-apps/plugins-workspace/commit/24154472a6710a690173df0a121125d1f1b871e8) ([#3287](https://github.com/tauri-apps/plugins-workspace/pull/3287) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Re-use `message` command in Rust side for `ask` and `confirm` commands, `allow-ask` and `allow-confirm` permissions are now aliases to `allow-message` + +### Dependencies + +- Upgraded to `fs-js@2.5.0` + +## \[2.6.0] + +- [`d7a0bb32`](https://github.com/tauri-apps/plugins-workspace/commit/d7a0bb325dad919d6cc132eb3898c33540de77c4) ([#3136](https://github.com/tauri-apps/plugins-workspace/pull/3136) by [@onehumandev](https://github.com/tauri-apps/plugins-workspace/../../onehumandev)) Add `fileAccessMode` option to file picker. + +## \[2.5.0] + +- [`dff6fa98`](https://github.com/tauri-apps/plugins-workspace/commit/dff6fa986a9a05ba98b6ca660fea78ae97251fc2) ([#3034](https://github.com/tauri-apps/plugins-workspace/pull/3034) by [@onehumandev](https://github.com/tauri-apps/plugins-workspace/../../onehumandev)) Add `pickerMode` option to file picker (currently only used on iOS) + +### Dependencies + +- Upgraded to `fs-js@2.4.5` + +### feat + +- [`c23fa03f`](https://github.com/tauri-apps/plugins-workspace/commit/c23fa03f07d5c1c220bcf0bca482364513e3f754) ([#3098](https://github.com/tauri-apps/plugins-workspace/pull/3098) by [@Lepidopteran](https://github.com/tauri-apps/plugins-workspace/../../Lepidopteran)) Add `xdg-portal` as an optional feature for `rfd` + +## \[2.4.2] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +### Dependencies + +- Upgraded to `fs-js@2.4.4` + +## \[2.4.1] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. + +### Dependencies + +- Upgraded to `fs-js@2.4.3` + +## \[2.4.0] + +- [`509eba8d`](https://github.com/tauri-apps/plugins-workspace/commit/509eba8d441c4f6ecf0af77b572cb2afd69a752d) ([#2641](https://github.com/tauri-apps/plugins-workspace/pull/2641) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add support for showing a message dialog with 3 buttons. + +## \[2.3.3] + +### Dependencies + +- Upgraded to `fs-js@2.4.2` + +## \[2.3.2] + +- [`af08c66f`](https://github.com/tauri-apps/plugins-workspace/commit/af08c66faafe0dffc4b0a80aef030cd3f0f89a9c) ([#2871](https://github.com/tauri-apps/plugins-workspace/pull/2871) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused the file picker not to open on Android when extension filters were set. + +## \[2.3.1] + +### Dependencies + +- Upgraded to `fs-js@2.4.1` + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +### Dependencies + +- Upgraded to `fs-js@2.4.0` + +## \[2.2.2] + +### Dependencies + +- Upgraded to `fs-js@2.3.0` + +## \[2.2.1] + +### Dependencies + +- Upgraded to `fs-js@2.2.1` + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +### Dependencies + +- Upgraded to `fs-js@2.1.0` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `fs-js@2.0.4` + +## \[2.0.4] + +- [`76f99ce9`](https://github.com/tauri-apps/plugins-workspace/commit/76f99ce999a2ff9e40235c1675e3eb6570b5e1e2) ([#2108](https://github.com/tauri-apps/plugins-workspace/pull/2108) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The `Dialog` struct is now correctly exported, primarily to fix the documentation on `docs.rs`. + +### Dependencies + +- Upgraded to `fs@2.1.0` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `fs@2.0.3` + +## \[2.0.1] + +- [`2302c2db`](https://github.com/tauri-apps/plugins-workspace/commit/2302c2db1c49673e61dcbda8cdb01b2c57e9ba6f) ([#1910](https://github.com/tauri-apps/plugins-workspace/pull/1910) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `ask` and `confirm` not using system button texts +- [`aee14ed4`](https://github.com/tauri-apps/plugins-workspace/commit/aee14ed4261cdedc4ed7cc2686f01f437859a5c7) ([#1892](https://github.com/tauri-apps/plugins-workspace/pull/1892) by [@nashaofu](https://github.com/tauri-apps/plugins-workspace/../../nashaofu)) Set `save` dialog mime type from the `filters` extensions on Android. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `fs@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `fs@2.0.0` + +## \[2.0.0-rc.8] + +- [`6bf1bd8d`](https://github.com/tauri-apps/plugins-workspace/commit/6bf1bd8d44bb95618590aa066e638509b014e0f9) ([#1805](https://github.com/tauri-apps/plugins-workspace/pull/1805) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update rfd to 0.15 + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.6` + +### breaking + +- [`04459afb`](https://github.com/tauri-apps/plugins-workspace/commit/04459afbb67aafa5cd57e6a148c2beb0a8d3e04a) ([#1842](https://github.com/tauri-apps/plugins-workspace/pull/1842) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Changed `MessageDialogBuilder::ok_button_label` and `MessageDialogBuilder::cancel_button_label` to `MessageDialogBuilder::buttons` which takes an enum now + +## \[2.0.0-rc.7] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.5` + +## \[2.0.0-rc.6] + +- [`2b898f07`](https://github.com/tauri-apps/plugins-workspace/commit/2b898f078688c57309ca17962bf02e665c406514) ([#1769](https://github.com/tauri-apps/plugins-workspace/pull/1769) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update Tauri scopes (asset protocol) when using the `open()` command to select directories. + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.4` + +## \[2.0.0-rc.5] + +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add utility methods on `FilePath` and `SafeFilePath` enums which are: + + - `path` + - `simplified` + - `into_path` +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Implement `Serialize`, `Deserialize`, `From`, `TryFrom` and `FromStr` traits for `FilePath` and `SafeFilePath` enums. +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Mark `Error` enum as `#[non_exhuastive]`. +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `SafeFilePath` enum. + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.3` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.2` + +### breaking + +- [`0cb99bda`](https://github.com/tauri-apps/plugins-workspace/commit/0cb99bdaf11b5a9bb66b80bdf40b085d87c3066d) ([#1706](https://github.com/tauri-apps/plugins-workspace/pull/1706) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) If no filters are specified, the file picker dialog now defaults to a file selection instead of photos. + +### feat + +- [`feb1e93f`](https://github.com/tauri-apps/plugins-workspace/commit/feb1e93fcb9a913c002daa29e3b709f24b97c664) ([#1707](https://github.com/tauri-apps/plugins-workspace/pull/1707) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Implement `save` API on iOS. + +## \[2.0.0-rc.1] + +- [`448846b8`](https://github.com/tauri-apps/plugins-workspace/commit/448846b834d23df6e7c5dc66c5dd9aa0cb01846d) ([#1658](https://github.com/tauri-apps/plugins-workspace/pull/1658) by [@mikoto2000](https://github.com/tauri-apps/plugins-workspace/../../mikoto2000)) The `open` function now returns a string representing either the file path or URI instead of an object. + To read the file data, use the `fs` APIs. +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +### feat + +- [`bc7eecf4`](https://github.com/tauri-apps/plugins-workspace/commit/bc7eecf4202e7d23b987440c1cbd2da0f68c8bef) ([#1657](https://github.com/tauri-apps/plugins-workspace/pull/1657) by [@mikoto2000](https://github.com/tauri-apps/plugins-workspace/../../mikoto2000)) Implement `save` API on Android. + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.6] + +- [`326df688`](https://github.com/tauri-apps/plugins-workspace/commit/326df6883998d416fc0837583ed972854628bb52)([#1236](https://github.com/tauri-apps/plugins-workspace/pull/1236)) Fixes command argument parsing on iOS. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.6` + +## \[2.0.0-beta.5] + +- [`bb51a41`](https://github.com/tauri-apps/plugins-workspace/commit/bb51a41d67ebf989e8aedf10c4b1a7f9514d1bdf)([#1168](https://github.com/tauri-apps/plugins-workspace/pull/1168)) **Breaking Change:** All apis that return paths to the frontend will now remove the `\\?\` UNC prefix on Windows. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.5` + +## \[2.0.0-beta.4] + +- [`4cd8112`](https://github.com/tauri-apps/plugins-workspace/commit/4cd81126fdf25e1847546f8fdbd924aa4bfeabb5)([#1056](https://github.com/tauri-apps/plugins-workspace/pull/1056)) Fixed an issue where dialogs on android would return the Content URI instead of the file path + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.4` + +## \[2.0.0-beta.3] + +- [`35ea595`](https://github.com/tauri-apps/plugins-workspace/commit/35ea5956d060f0bdafd140f2541c607bb811805b)([#1073](https://github.com/tauri-apps/plugins-workspace/pull/1073)) Fixed an issue where the dialog apis panicked when they were called with no application windows open. +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.3` + +## \[2.0.0-beta.2] + +- [`aa25c91`](https://github.com/tauri-apps/plugins-workspace/commit/aa25c91bb01e957872fb2b606a3acaf2f2c4ef3a)([#978](https://github.com/tauri-apps/plugins-workspace/pull/978)) Allow configuring `canCreateDirectories` for open and save dialogs on macOS, if not configured, it will be set to `true` by default. +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.7] + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.7` + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. +- [`2e2c0a1`](https://github.com/tauri-apps/plugins-workspace/commit/2e2c0a1b958fcbc7de855ef1d305b11855f2eb8e)([#769](https://github.com/tauri-apps/plugins-workspace/pull/769)) Fix incorrect result for dialog messages. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.2` + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! + d6e80b)([#545](https://github.com/tauri-apps/plugins-workspace/pull/545)) Fixes docs.rs build by enabling the `tauri/dox` feature flag. +g. diff --git a/plugins/dialog/Cargo.toml b/plugins/dialog/Cargo.toml new file mode 100644 index 0000000..3064b1e --- /dev/null +++ b/plugins/dialog/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "tauri-plugin-dialog" +version = "2.7.3" +description = "Native system dialogs for opening and saving files along with message dialogs on your Tauri application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-dialog" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/test", + "/tsconfig.json", +] + +[features] +default = ["gtk3"] +xdg-portal = ["rfd/xdg-portal", "rfd/tokio", "rfd/wayland"] +gtk3 = ["rfd/gtk3"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu", "x86_64-linux-android"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "partial", notes = "Does not support folder picker" } +ios = { level = "partial", notes = "Does not support folder picker" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +tauri-plugin-fs = { path = "../fs", version = "2.5.2" } + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } + +[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] +rfd = { version = "0.16", default-features = false, features = [ + "common-controls-v6", +] } + +raw-window-handle = "0.6" diff --git a/plugins/dialog/LICENSE.spdx b/plugins/dialog/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/dialog/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/dialog/LICENSE_APACHE-2.0 b/plugins/dialog/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/dialog/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/dialog/LICENSE_MIT b/plugins/dialog/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/dialog/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/dialog/README.md b/plugins/dialog/README.md new file mode 100644 index 0000000..e062c80 --- /dev/null +++ b/plugins/dialog/README.md @@ -0,0 +1,107 @@ +![plugin-dialog](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/dialog/banner.png) + +Native system dialogs for opening and saving files along with message dialogs. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-dialog = "2.0.0" +# alternatively with Git: +tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +### Linux XDG Desktop Portal Support + +By default, this plugin uses gtk to show dialogs, however since `v2.5.0` you can switch to using [XDG Desktop Portal](https://flatpak.github.io/xdg-desktop-portal/) by adding the following to your `Cargo.toml` file: + +```toml +[dependencies] +tauri-plugin-dialog = { version = "2.5.0", default-features = false, features = ["xdg-portal"] } +# alternatively with Git: +tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2", default-features = false, features = ["xdg-portal"] } + +``` + +Do note if you use the `xdg-portal` feature, you need to ensure that [`zenity`](https://gitlab.gnome.org/GNOME/zenity) and an [XDG Desktop Portal backend](https://flatpak.github.io/xdg-desktop-portal#using-portals) is installed with your program. + +For more information, see [XDG Desktop Portal documentation](https://flatpak.github.io/xdg-desktop-portal/) and [`rfd` documentation](https://docs.rs/rfd/latest/rfd#xdg-desktop-portal-backend). + +### JavaScript + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-dialog +# or +npm add @tauri-apps/plugin-dialog +# or +yarn add @tauri-apps/plugin-dialog +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript + +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/dialog/SECURITY.md b/plugins/dialog/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/dialog/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/dialog/android/.gitignore b/plugins/dialog/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/dialog/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/dialog/android/build.gradle.kts b/plugins/dialog/android/build.gradle.kts new file mode 100644 index 0000000..96abbf5 --- /dev/null +++ b/plugins/dialog/android/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.dialog" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("com.google.android.material:material:1.13.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/dialog/android/consumer-rules.pro b/plugins/dialog/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/dialog/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/dialog/android/settings.gradle b/plugins/dialog/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/dialog/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/dialog/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/dialog/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6c9d837 --- /dev/null +++ b/plugins/dialog/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.dialog + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("app.tauri.dialog", appContext.packageName) + } +} diff --git a/plugins/dialog/android/src/main/AndroidManifest.xml b/plugins/dialog/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/plugins/dialog/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/plugins/dialog/android/src/main/java/DialogPlugin.kt b/plugins/dialog/android/src/main/java/DialogPlugin.kt new file mode 100644 index 0000000..b11c201 --- /dev/null +++ b/plugins/dialog/android/src/main/java/DialogPlugin.kt @@ -0,0 +1,246 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.dialog + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.webkit.MimeTypeMap +import androidx.activity.result.ActivityResult +import app.tauri.Logger +import app.tauri.annotation.ActivityCallback +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +@InvokeArg +class Filter { + lateinit var extensions: Array +} + +@InvokeArg +class FilePickerOptions { + lateinit var filters: Array + var multiple: Boolean? = null + var pickerMode: String? = null +} + +@InvokeArg +class MessageOptions { + var title: String? = null + lateinit var message: String + var okButtonLabel: String? = null + var noButtonLabel: String? = null + var cancelButtonLabel: String? = null +} + +@InvokeArg +class SaveFileDialogOptions { + var fileName: String? = null + lateinit var filters: Array +} + +@TauriPlugin +class DialogPlugin(private val activity: Activity): Plugin(activity) { + var filePickerOptions: FilePickerOptions? = null + + @Command + fun showFilePicker(invoke: Invoke) { + try { + val args = invoke.parseArgs(FilePickerOptions::class.java) + val parsedTypes = parseFiltersOption(args.filters) + + // TODO: ACTION_OPEN_DOCUMENT ?? + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + if (args.pickerMode == "image") { + intent.type = "image/*" + } else if (args.pickerMode == "video") { + intent.type = "video/*" + } else if (args.pickerMode == "media") { + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "image/*")) + } else if (parsedTypes.isNotEmpty()) { + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_MIME_TYPES, parsedTypes) + } else { + intent.type = "*/*" + } + + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, args.multiple ?: false) + + startActivityForResult(invoke, intent, "filePickerResult") + } catch (ex: Exception) { + val message = ex.message ?: "Failed to pick file" + Logger.error(message) + invoke.reject(message) + } + } + + @ActivityCallback + fun filePickerResult(invoke: Invoke, result: ActivityResult) { + try { + when (result.resultCode) { + Activity.RESULT_OK -> { + val callResult = createPickFilesResult(result.data) + invoke.resolve(callResult) + } + Activity.RESULT_CANCELED -> invoke.reject("File picker cancelled") + else -> invoke.reject("Failed to pick files") + } + } catch (ex: java.lang.Exception) { + val message = ex.message ?: "Failed to read file pick result" + Logger.error(message) + invoke.reject(message) + } + } + + private fun createPickFilesResult(data: Intent?): JSObject { + val callResult = JSObject() + if (data == null) { + callResult.put("files", null) + return callResult + } + val uris: MutableList = ArrayList() + if (data.clipData == null) { + val uri: Uri? = data.data + uris.add(uri?.toString()) + } else { + for (i in 0 until data.clipData!!.itemCount) { + val uri: Uri = data.clipData!!.getItemAt(i).uri + uris.add(uri.toString()) + } + } + callResult.put("files", JSArray.from(uris.toTypedArray())) + return callResult + } + + private fun parseFiltersOption(filters: Array): Array { + val mimeTypes = mutableListOf() + for (filter in filters) { + for (ext in filter.extensions) { + if (ext.contains('/')) { + mimeTypes.add(if (ext == "text/csv") "text/comma-separated-values" else ext) + } else { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.let { + mimeTypes.add(it) + } + } + } + } + return mimeTypes.toTypedArray() + } + + @Command + fun showMessageDialog(invoke: Invoke) { + val args = invoke.parseArgs(MessageOptions::class.java) + + if (activity.isFinishing) { + invoke.reject("App is finishing") + return + } + + val handler = { value: String -> + val ret = JSObject() + ret.put("value", value) + invoke.resolve(ret) + } + + Handler(Looper.getMainLooper()) + .post { + val builder = MaterialAlertDialogBuilder(activity) + + if (args.title != null) { + builder.setTitle(args.title) + } + + val okButtonLabel = args.okButtonLabel ?: "Ok" + + builder + .setMessage(args.message) + .setPositiveButton(okButtonLabel) { dialog, _ -> + dialog.dismiss() + handler(okButtonLabel) + } + .setOnCancelListener { dialog -> + dialog.dismiss() + handler(args.cancelButtonLabel ?: "Cancel") + } + + if (args.noButtonLabel != null) { + builder.setNeutralButton(args.noButtonLabel) { dialog, _ -> + dialog.dismiss() + handler(args.noButtonLabel!!) + } + } + + if (args.cancelButtonLabel != null) { + builder.setNegativeButton( args.cancelButtonLabel) { dialog, _ -> + dialog.dismiss() + handler(args.cancelButtonLabel!!) + } + } + + builder.show() + } + } + + @Command + fun saveFileDialog(invoke: Invoke) { + try { + val args = invoke.parseArgs(SaveFileDialogOptions::class.java) + val parsedTypes = parseFiltersOption(args.filters) + + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.putExtra(Intent.EXTRA_TITLE, args.fileName ?: "") + if (parsedTypes.size == 1) { + intent.type = parsedTypes.first() + } else { + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_MIME_TYPES, parsedTypes) + } + + startActivityForResult(invoke, intent, "saveFileDialogResult") + } catch (ex: Exception) { + val message = ex.message ?: "Failed to pick save file" + Logger.error(message) + invoke.reject(message) + } + } + + @ActivityCallback + fun saveFileDialogResult(invoke: Invoke, result: ActivityResult) { + try { + when (result.resultCode) { + Activity.RESULT_OK -> { + val callResult = JSObject() + val intent: Intent? = result.data + if (intent != null) { + val uri = intent.data + if (uri != null) { + callResult.put("file", uri.toString()) + } + } + invoke.resolve(callResult) + } + Activity.RESULT_CANCELED -> invoke.reject("File picker cancelled") + else -> invoke.reject("Failed to pick files") + } + } catch (ex: java.lang.Exception) { + val message = ex.message ?: "Failed to read file pick result" + Logger.error(message) + invoke.reject(message) + } + } +} \ No newline at end of file diff --git a/plugins/dialog/android/src/main/java/FilePickerUtils.kt b/plugins/dialog/android/src/main/java/FilePickerUtils.kt new file mode 100644 index 0000000..7029d4c --- /dev/null +++ b/plugins/dialog/android/src/main/java/FilePickerUtils.kt @@ -0,0 +1,230 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.dialog + + +import android.content.ContentUris +import android.database.Cursor +import android.provider.MediaStore +import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.util.Base64 +import app.tauri.Logger +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream + +class FilePickerUtils { + class FileResolution(var height: Int, var width: Int) + + companion object { + fun getPathFromUri(context: Context, uri: Uri): String? { + if (DocumentsContract.isDocumentUri(context, uri)) { + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":") + return if ("primary".equals(split[0], ignoreCase = true)) { + "${Environment.getExternalStorageDirectory()}/${split[1]}" + } else { + null + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":") + val contentUri: Uri? = when (split[0]) { + "image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + "video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + "audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> null + } + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals(uri.scheme, ignoreCase = true)) { + return getDataColumn(context, uri, null, null) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + return uri.path + } + return null + } + + fun getNameFromUri(context: Context, uri: Uri): String? { + var displayName: String? = "" + val projection = arrayOf(OpenableColumns.DISPLAY_NAME) + val cursor = + context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null) { + cursor.moveToFirst() + val columnIdx = cursor.getColumnIndex(projection[0]) + displayName = cursor.getString(columnIdx) + cursor.close() + } + if (displayName.isNullOrEmpty()) { + displayName = uri.lastPathSegment + } + return displayName + } + + fun getDataFromUri(context: Context, uri: Uri): String { + try { + val stream = context.contentResolver.openInputStream(uri) ?: return "" + val bytes = getBytesFromInputStream(stream) + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } catch (e: FileNotFoundException) { + Logger.error("openInputStream failed.", e) + } catch (e: IOException) { + Logger.error("getBytesFromInputStream failed.", e) + } + return "" + } + + fun getMimeTypeFromUri(context: Context, uri: Uri): String? { + return context.contentResolver.getType(uri) + } + + fun getModifiedAtFromUri(context: Context, uri: Uri): Long? { + return try { + var modifiedAt: Long = 0 + val cursor = + context.contentResolver.query(uri, null, null, null, null) + if (cursor != null) { + cursor.moveToFirst() + val columnIdx = + cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + modifiedAt = cursor.getLong(columnIdx) + cursor.close() + } + modifiedAt + } catch (e: Exception) { + Logger.error("getModifiedAtFromUri failed.", e) + null + } + } + + fun getSizeFromUri(context: Context, uri: Uri): Long { + var size: Long = 0 + val projection = arrayOf(OpenableColumns.SIZE) + val cursor = + context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null) { + cursor.moveToFirst() + val columnIdx = cursor.getColumnIndex(projection[0]) + size = cursor.getLong(columnIdx) + cursor.close() + } + return size + } + + fun getDurationFromUri(context: Context, uri: Uri): Long? { + if (isVideoUri(context, uri)) { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + val durationMs = time?.toLong() ?: 0 + try { + retriever.release() + } catch (e: Exception) { + Logger.error("MediaMetadataRetriever.release() failed.", e) + } + return durationMs / 1000L + } + return null + } + + fun getHeightAndWidthFromUri(context: Context, uri: Uri): FileResolution? { + if (isImageUri(context, uri)) { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + return try { + BitmapFactory.decodeStream( + context.contentResolver.openInputStream(uri), + null, + options + ) + FileResolution(options.outHeight, options.outWidth) + } catch (exception: FileNotFoundException) { + exception.printStackTrace() + null + } + } else if (isVideoUri(context, uri)) { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + val width = + Integer.valueOf(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?: "0") + val height = + Integer.valueOf(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: "0") + try { + retriever.release() + } catch (e: Exception) { + Logger.error("MediaMetadataRetriever.release() failed.", e) + } + return FileResolution(height, width) + } + return null + } + + private fun isImageUri(context: Context, uri: Uri): Boolean { + val mimeType = getMimeTypeFromUri(context, uri) ?: return false + return mimeType.startsWith("image") + } + + private fun isVideoUri(context: Context, uri: Uri): Boolean { + val mimeType = getMimeTypeFromUri(context, uri) ?: return false + return mimeType.startsWith("video") + } + + @Throws(IOException::class) + private fun getBytesFromInputStream(`is`: InputStream): ByteArray { + val os = ByteArrayOutputStream() + val buffer = ByteArray(0xFFFF) + var len = `is`.read(buffer) + while (len != -1) { + os.write(buffer, 0, len) + len = `is`.read(buffer) + } + return os.toByteArray() + } + } +} + +private fun getDataColumn(context: Context, uri: Uri?, selection: String?, selectionArgs: Array?): String? { + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf(column) + try { + cursor = context.contentResolver.query(uri!!, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndexOrThrow(column) + return cursor.getString(columnIndex) + } + } finally { + cursor?.close() + } + return null +} + +private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority +} + +private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority +} + +private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority +} diff --git a/plugins/dialog/android/src/test/java/ExampleUnitTest.kt b/plugins/dialog/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..00369a3 --- /dev/null +++ b/plugins/dialog/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.dialog + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/dialog/api-iife.js b/plugins/dialog/api-iife.js new file mode 100644 index 0000000..8c11c21 --- /dev/null +++ b/plugins/dialog/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_DIALOG__=function(n,e){"use strict";function t(n){if(void 0!==n)return"string"==typeof n?n:"ok"in n&&"cancel"in n?{OkCancelCustom:[n.ok,n.cancel]}:"yes"in n&&"no"in n&&"cancel"in n?{YesNoCancelCustom:[n.yes,n.no,n.cancel]}:"ok"in n?{OkCustom:n.ok}:void 0}async function o(n,o){return await e.invoke("plugin:dialog|message",{message:n,title:o?.title,kind:o?.kind,buttons:t(o?.buttons)})}return n.ask=async function(n,e){const t="string"==typeof e?{title:e}:e,i=t?.okLabel||t?.cancelLabel,a=t?.okLabel??"Yes";return await o(n,{title:t?.title,kind:t?.kind,buttons:i?{ok:a,cancel:t.cancelLabel??"No"}:"YesNo"})===a},n.confirm=async function(n,e){const t="string"==typeof e?{title:e}:e,i=t?.okLabel||t?.cancelLabel,a=t?.okLabel??"Ok";return await o(n,{title:t?.title,kind:t?.kind,buttons:i?{ok:a,cancel:t.cancelLabel??"Cancel"}:"OkCancel"})===a},n.message=async function(n,e){const t="string"==typeof e?{title:e}:e;return t&&!t.buttons&&t.okLabel&&(t.buttons={ok:t.okLabel}),o(n,t)},n.open=async function(n={}){return"object"==typeof n&&Object.freeze(n),await e.invoke("plugin:dialog|open",{options:n})},n.save=async function(n={}){return"object"==typeof n&&Object.freeze(n),await e.invoke("plugin:dialog|save",{options:n})},n}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"dialog",{value:__TAURI_PLUGIN_DIALOG__})} diff --git a/plugins/dialog/banner.png b/plugins/dialog/banner.png new file mode 100644 index 0000000..88be34e Binary files /dev/null and b/plugins/dialog/banner.png differ diff --git a/plugins/dialog/build.rs b/plugins/dialog/build.rs new file mode 100644 index 0000000..a94f61a --- /dev/null +++ b/plugins/dialog/build.rs @@ -0,0 +1,18 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["open", "save", "message"]; + +fn main() { + let result = tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .try_build(); + + // when building documentation for Android the plugin build result is always Err() and is irrelevant to the crate documentation build + if !(cfg!(docsrs) && std::env::var("TARGET").unwrap().contains("android")) { + result.unwrap(); + } +} diff --git a/plugins/dialog/guest-js/index.ts b/plugins/dialog/guest-js/index.ts new file mode 100644 index 0000000..2ca5781 --- /dev/null +++ b/plugins/dialog/guest-js/index.ts @@ -0,0 +1,554 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Native system dialogs for opening and saving files, along with message, ask and confirm dialogs. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * Extension filters for the file dialog. + * + * @since 2.0.0 + */ +interface DialogFilter { + /** The name of the filter, shown to the user in the dialog's filter selector. */ + name: string + /** + * Extensions to filter, without a `.` prefix. + * + * **Note:** Mobile platforms have different APIs for filtering that may not support extensions. + * iOS: Extensions are supported in the document picker, but not in the media picker. + * Android: Extensions are not supported. + * + * For these platforms, MIME types are the primary way to filter files, as opposed to extensions. + * This means the string values here labeled as `extensions` may also be a MIME type. + * This property name of `extensions` is being kept for backwards compatibility, but this may be revisited to + * specify the difference between extension or MIME type filtering. + * + * @example + * ```typescript + * extensions: ['svg', 'png'] + * ``` + */ + extensions: string[] +} + +/** + * Options for the open dialog. + * + * @since 2.0.0 + */ +interface OpenDialogOptions { + /** The title of the dialog window (desktop only). */ + title?: string + /** + * The filters of the dialog. + * On mobile platforms, if either: + * A) the {@linkcode pickerMode} is set to `media`, `image`, or `video` + * -- or -- + * B) the filters include **only** either image or video mime types, the media picker will be displayed. + * Otherwise, the document picker will be displayed. + */ + filters?: DialogFilter[] + /** + * Initial directory or file path. + * If it's a directory path, the dialog interface will change to that folder. + * If it's not an existing directory, the file name will be set to the dialog's file name input and the dialog will be set to the parent folder. + * + * On mobile the file name is always used on the dialog's file name input. + * If not provided, Android uses `(invalid).txt` as default file name. + */ + defaultPath?: string + /** Whether the dialog allows multiple selection or not. */ + multiple?: boolean + /** Whether the dialog is a directory selection or not. */ + directory?: boolean + /** + * If `directory` is true, indicates that it will be read recursively later. + * Defines whether subdirectories will be allowed on the scope or not. + */ + recursive?: boolean + /** Whether to allow creating directories in the dialog. Enabled by default. **macOS Only** */ + canCreateDirectories?: boolean + /** + * The preferred mode of the dialog. + * This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers. + * If not provided, the dialog will automatically choose the best mode based on the MIME types or extensions of the {@linkcode filters}. + * On desktop, this option is ignored. + */ + pickerMode?: PickerMode + /** + * The file access mode of the dialog. + * If not provided, `copy` is used, which matches the behavior of the {@linkcode open} method before the introduction of this option. + * + * **Usage** + * If a file is opened with {@linkcode fileAccessMode: 'copy'}, it will be copied to the app's sandbox. + * This means the file can be read, edited, deleted, copied, or any other operation without any issues, since the file + * now belongs to the app. + * This also means that the caller has responsibility of deleting the file if this file is not meant to be retained + * in the app sandbox. + * + * If a file is opened with {@linkcode fileAccessMode: 'scoped'}, the file will remain in its original location + * and security-scoped access will be automatically managed by the system. + * + * **Note** + * This is specifically meant for document pickers on iOS or MacOS, in conjunction with [security scoped resources](https://developer.apple.com/documentation/foundation/nsurl/startaccessingsecurityscopedresource()). + * + * Why only document pickers, and not image or video pickers? + * The image and video pickers on iOS behave differently from the document pickers, and return [NSItemProvider](https://developer.apple.com/documentation/foundation/nsitemprovider) objects instead of file URLs. + * These are meant to be ephemeral (only available within the callback of the picker), and are not accessible outside of the callback. + * So for image and video pickers, the only way to access the file is to copy it to the app's sandbox, and this is the URL that is returned from this API. + * This means there is no provision for using `scoped` mode with image or video pickers. + * If an image or video picker is used, `copy` is always used. + */ + fileAccessMode?: FileAccessMode +} + +/** + * Options for the save dialog. + * + * @since 2.0.0 + */ +interface SaveDialogOptions { + /** The title of the dialog window (desktop only). */ + title?: string + /** The filters of the dialog. */ + filters?: DialogFilter[] + /** + * Initial directory or file path. + * If it's a directory path, the dialog interface will change to that folder. + * If it's not an existing directory, the file name will be set to the dialog's file name input and the dialog will be set to the parent folder. + * + * On mobile the file name is always used on the dialog's file name input. + * If not provided, Android uses `(invalid).txt` as default file name. + */ + defaultPath?: string + /** Whether to allow creating directories in the dialog. Enabled by default. **macOS Only** */ + canCreateDirectories?: boolean +} + +/** + * The preferred mode of the dialog. + * This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers. + * On desktop, this option is ignored. + * If not provided, the dialog will automatically choose the best mode based on the MIME types or extensions of the {@linkcode filters}. + * + * **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below. + */ +export type PickerMode = 'document' | 'media' | 'image' | 'video' + +/** + * The file access mode of the dialog. + * + * - `copy`: copy/move the picked file to the app sandbox; no scoped access required. + * - `scoped`: keep file in place; security-scoped access is automatically managed. + * + * **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below. + */ +export type FileAccessMode = 'copy' | 'scoped' + +/** + * Default buttons for a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogDefaultButtons = + | 'Ok' + | 'OkCancel' + | 'YesNo' + | 'YesNoCancel' + +/** All possible button keys. */ +type ButtonKey = 'ok' | 'cancel' | 'yes' | 'no' + +/** Ban everything except a set of keys. */ +type BanExcept = Partial< + Record, never> +> + +/** + * The Yes, No and Cancel buttons of a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogButtonsYesNoCancel = { + /** The label of the Yes button. */ + yes: string + /** The label of the No button. */ + no: string + /** The label of the Cancel button. */ + cancel: string +} & BanExcept<'yes' | 'no' | 'cancel'> + +/** + * The Ok and Cancel buttons of a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogButtonsOkCancel = { + /** The label of the Ok button. */ + ok: string + /** The label of the Cancel button. */ + cancel: string +} & BanExcept<'ok' | 'cancel'> + +/** + * The Ok button of a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogButtonsOk = { + /** The label of the Ok button. */ + ok: string +} & BanExcept<'ok'> + +/** + * Custom buttons for a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogCustomButtons = + | MessageDialogButtonsYesNoCancel + | MessageDialogButtonsOkCancel + | MessageDialogButtonsOk + +/** + * The buttons of a message dialog. + * + * @since 2.4.0 + */ +export type MessageDialogButtons = + | MessageDialogDefaultButtons + | MessageDialogCustomButtons + +/** + * Options for the message dialog. + * + * @since 2.0.0 + */ +interface MessageDialogOptions { + /** The title of the dialog. Defaults to the app name. */ + title?: string + /** The kind of the dialog. Defaults to `info`. */ + kind?: 'info' | 'warning' | 'error' + /** + * The label of the Ok button. + * + * @deprecated Use {@linkcode MessageDialogOptions.buttons} instead. + */ + okLabel?: string + /** + * The buttons of the dialog. + * + * @example + * + * ```ts + * // Use system default buttons texts + * await message('Hello World!', { buttons: 'Ok' }) + * await message('Hello World!', { buttons: 'OkCancel' }) + * + * // Or with custom button texts + * await message('Hello World!', { buttons: { ok: 'Yes!' } }) + * await message('Take on the task?', { + * buttons: { ok: 'Accept', cancel: 'Cancel' } + * }) + * await message('Show the file content?', { + * buttons: { yes: 'Show content', no: 'Show in folder', cancel: 'Cancel' } + * }) + * ``` + * + * @since 2.4.0 + */ + buttons?: MessageDialogButtons +} + +/** + * Internal function to convert the buttons to the Rust type. + */ +function buttonsToRust(buttons: MessageDialogButtons | undefined) { + if (buttons === undefined) { + return undefined + } + + if (typeof buttons === 'string') { + return buttons + } else if ('ok' in buttons && 'cancel' in buttons) { + return { OkCancelCustom: [buttons.ok, buttons.cancel] } + } else if ('yes' in buttons && 'no' in buttons && 'cancel' in buttons) { + return { + YesNoCancelCustom: [buttons.yes, buttons.no, buttons.cancel] + } + } else if ('ok' in buttons) { + return { OkCustom: buttons.ok } + } + + return undefined +} + +/** + * Options for the {@linkcode ask} and {@linkcode confirm} dialogs. + * + * @since 2.0.0 + */ +interface ConfirmDialogOptions { + /** The title of the dialog. Defaults to the app name. */ + title?: string + /** The kind of the dialog. Defaults to `info`. */ + kind?: 'info' | 'warning' | 'error' + /** The label of the confirm button. */ + okLabel?: string + /** The label of the cancel button. */ + cancelLabel?: string +} + +/** + * The return type of {@linkcode open}, derived from its options: a single path, an array of + * paths when {@linkcode OpenDialogOptions.multiple} is `true`, or `null` when the user cancels. + * + * @since 2.0.0 + */ +type OpenDialogReturn = T['directory'] extends true + ? T['multiple'] extends true + ? string[] | null + : string | null + : T['multiple'] extends true + ? string[] | null + : string | null + +/** + * Open a file/directory selection dialog. + * + * The selected paths are added to the filesystem and asset protocol scopes. + * When security is more important than the easy of use of this API, + * prefer writing a dedicated command instead. + * + * Note that the scope change is not persisted, so the values are cleared when the application is restarted. + * You can save it to the filesystem using [tauri-plugin-persisted-scope](https://github.com/tauri-apps/tauri-plugin-persisted-scope). + * @example + * ```typescript + * import { open } from '@tauri-apps/plugin-dialog'; + * // Open a selection dialog for image files + * const selected = await open({ + * multiple: true, + * filters: [{ + * name: 'Image', + * extensions: ['png', 'jpeg'] + * }] + * }); + * if (Array.isArray(selected)) { + * // user selected multiple files + * } else if (selected === null) { + * // user cancelled the selection + * } else { + * // user selected a single file + * } + * ``` + * + * @example + * ```typescript + * import { open } from '@tauri-apps/plugin-dialog'; + * import { appDir } from '@tauri-apps/api/path'; + * // Open a selection dialog for directories + * const selected = await open({ + * directory: true, + * multiple: true, + * defaultPath: await appDir(), + * }); + * if (Array.isArray(selected)) { + * // user selected multiple directories + * } else if (selected === null) { + * // user cancelled the selection + * } else { + * // user selected a single directory + * } + * ``` + * + * @param options The dialog's options. + * + * @returns A promise resolving to the selected path(s) + * + * @since 2.0.0 + */ +async function open( + options: T = {} as T +): Promise> { + if (typeof options === 'object') { + Object.freeze(options) + } + + return await invoke('plugin:dialog|open', { options }) +} + +/** + * Open a file/directory save dialog. + * + * The selected path is added to the filesystem and asset protocol scopes. + * When security is more important than the easy of use of this API, + * prefer writing a dedicated command instead. + * + * Note that the scope change is not persisted, so the values are cleared when the application is restarted. + * You can save it to the filesystem using [tauri-plugin-persisted-scope](https://github.com/tauri-apps/tauri-plugin-persisted-scope). + * @example + * ```typescript + * import { save } from '@tauri-apps/plugin-dialog'; + * const filePath = await save({ + * filters: [{ + * name: 'Image', + * extensions: ['png', 'jpeg'] + * }] + * }); + * ``` + * + * @param options The dialog's options. + * + * @returns A promise resolving to the selected path. + * + * @since 2.0.0 + */ +async function save(options: SaveDialogOptions = {}): Promise { + if (typeof options === 'object') { + Object.freeze(options) + } + + return await invoke('plugin:dialog|save', { options }) +} + +/** + * The result of a message dialog. + * + * The result is a string if the dialog has custom buttons, + * otherwise it is one of the default buttons. + * + * @since 2.4.0 + */ +export type MessageDialogResult = 'Yes' | 'No' | 'Ok' | 'Cancel' | (string & {}) + +async function messageCommand( + message: string, + options?: Omit +) { + return await invoke('plugin:dialog|message', { + message, + title: options?.title, + kind: options?.kind, + buttons: buttonsToRust(options?.buttons) + }) +} + +/** + * Shows a message dialog with an `Ok` button. + * @example + * ```typescript + * import { message } from '@tauri-apps/plugin-dialog'; + * await message('Tauri is awesome', 'Tauri'); + * await message('File not found', { title: 'Tauri', kind: 'error' }); + * ``` + * + * @param message The message to show. + * @param options The dialog's options. If a string, it represents the dialog title. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + * + */ +async function message( + message: string, + options?: string | MessageDialogOptions +): Promise { + const opts = typeof options === 'string' ? { title: options } : options + if (opts && !opts.buttons && opts.okLabel) { + opts.buttons = { ok: opts.okLabel } + } + return messageCommand(message, opts) +} + +/** + * Shows a question dialog with `Yes` and `No` buttons. + * + * Convenient wrapper for `await message('msg', { buttons: 'YesNo' }) === 'Yes'` + * + * @example + * ```typescript + * import { ask } from '@tauri-apps/plugin-dialog'; + * const yes = await ask('Are you sure?', 'Tauri'); + * const yes2 = await ask('This action cannot be reverted. Are you sure?', { title: 'Tauri', kind: 'warning' }); + * ``` + * + * @param message The message to show. + * @param options The dialog's options. If a string, it represents the dialog title. + * + * @returns A promise resolving to a boolean indicating whether `Yes` was clicked or not. + * + * @since 2.0.0 + */ +async function ask( + message: string, + options?: string | ConfirmDialogOptions +): Promise { + const opts = typeof options === 'string' ? { title: options } : options + const customButtons = opts?.okLabel || opts?.cancelLabel + const okLabel = opts?.okLabel ?? 'Yes' + return ( + (await messageCommand(message, { + title: opts?.title, + kind: opts?.kind, + buttons: customButtons + ? { ok: okLabel, cancel: opts.cancelLabel ?? 'No' } + : 'YesNo' + })) === okLabel + ) +} + +/** + * Shows a question dialog with `Ok` and `Cancel` buttons. + * + * Convenient wrapper for `await message('msg', { buttons: 'OkCancel' }) === 'Ok'` + * + * @example + * ```typescript + * import { confirm } from '@tauri-apps/plugin-dialog'; + * const confirmed = await confirm('Are you sure?', 'Tauri'); + * const confirmed2 = await confirm('This action cannot be reverted. Are you sure?', { title: 'Tauri', kind: 'warning' }); + * ``` + * + * @param message The message to show. + * @param options The dialog's options. If a string, it represents the dialog title. + * + * @returns A promise resolving to a boolean indicating whether `Ok` was clicked or not. + * + * @since 2.0.0 + */ +async function confirm( + message: string, + options?: string | ConfirmDialogOptions +): Promise { + const opts = typeof options === 'string' ? { title: options } : options + const customButtons = opts?.okLabel || opts?.cancelLabel + const okLabel = opts?.okLabel ?? 'Ok' + return ( + (await messageCommand(message, { + title: opts?.title, + kind: opts?.kind, + buttons: customButtons + ? { ok: okLabel, cancel: opts.cancelLabel ?? 'Cancel' } + : 'OkCancel' + })) === okLabel + ) +} + +export type { + DialogFilter, + OpenDialogOptions, + OpenDialogReturn, + SaveDialogOptions, + MessageDialogOptions, + ConfirmDialogOptions +} + +export { open, save, message, ask, confirm } diff --git a/plugins/dialog/guest-js/init.ts b/plugins/dialog/guest-js/init.ts new file mode 100644 index 0000000..f272c1b --- /dev/null +++ b/plugins/dialog/guest-js/init.ts @@ -0,0 +1,25 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Injected script that replaces `window.alert` and `window.confirm` with implementations backed + * by native dialogs. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +window.alert = function (message: string) { + void invoke('plugin:dialog|message', { + message: message.toString() + }) +} + +// @ts-expect-error tauri does not have sync IPC :( +window.confirm = async function (message: string) { + return await invoke('plugin:dialog|confirm', { + message: message.toString() + }) +} diff --git a/plugins/dialog/ios/.gitignore b/plugins/dialog/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/dialog/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/dialog/ios/Package.swift b/plugins/dialog/ios/Package.swift new file mode 100644 index 0000000..f8983f1 --- /dev/null +++ b/plugins/dialog/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-dialog", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-dialog", + type: .static, + targets: ["tauri-plugin-dialog"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-dialog", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/dialog/ios/README.md b/plugins/dialog/ios/README.md new file mode 100644 index 0000000..157cf03 --- /dev/null +++ b/plugins/dialog/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin Dialog + +A description of this package. diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift new file mode 100644 index 0000000..719c345 --- /dev/null +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -0,0 +1,313 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import MobileCoreServices +import Photos +import PhotosUI +import SwiftRs +import Tauri +import UIKit +import UniformTypeIdentifiers +import WebKit + +enum FilePickerEvent { + case selected([URL]) + case cancelled + case error(String) +} + +struct MessageDialogOptions: Decodable { + var title: String? + let message: String + var okButtonLabel: String? + var noButtonLabel: String? + var cancelButtonLabel: String? +} + +struct Filter: Decodable { + var extensions: [String]? +} + +struct FilePickerOptions: Decodable { + var multiple: Bool? + var filters: [Filter]? + var defaultPath: String? + var pickerMode: PickerMode? + var fileAccessMode: FileAccessMode? +} + +struct SaveFileDialogOptions: Decodable { + var fileName: String? + var defaultPath: String? +} + +enum FileAccessMode: String, Decodable { + case copy + case scoped +} + +enum PickerMode: String, Decodable { + case document + case media + case image + case video +} + +class DialogPlugin: Plugin { + + var filePickerController: FilePickerController! + var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil + + override init() { + super.init() + filePickerController = FilePickerController(self) + + } + + @objc public func showFilePicker(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(FilePickerOptions.self) + + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["files": urls]) + case .cancelled: + invoke.resolve(["files": nil]) + case .error(let error): + invoke.reject(error) + } + } + + if #available(iOS 14, *) { + let parsedTypes = parseFiltersOption(args.filters ?? []) + + let mimeKinds = Set( + parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] }) + let filtersIncludeImage = mimeKinds.contains("image") + let filtersIncludeVideo = mimeKinds.contains("video") + let filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" }) + + // If the picker mode is media, images, or videos, we always want to show the media picker regardless of what's in the filters. + // Otherwise, if the filters A) do not include non-media types and B) include either image or video, we want to show the media picker. + if args.pickerMode == .media + || args.pickerMode == .image + || args.pickerMode == .video + || (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo)) + { + DispatchQueue.main.async { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1 + + // If the filters include image or video, use the appropriate filter. + // If both are true, don't define a filter, which means we will display all media. + if args.pickerMode == .image || (filtersIncludeImage && !filtersIncludeVideo) { + configuration.filter = .images + } else if args.pickerMode == .video || (filtersIncludeVideo && !filtersIncludeImage) { + configuration.filter = .videos + } + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self.filePickerController + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } else { + DispatchQueue.main.async { + // The UTType.item is the catch-all, allowing for any file type to be selected. + let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes + let picker: UIDocumentPickerViewController = UIDocumentPickerViewController( + forOpeningContentTypes: contentTypes, + asCopy: args.fileAccessMode == .scoped ? false : true) + + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + + picker.delegate = self.filePickerController + picker.allowsMultipleSelection = args.multiple ?? false + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + } else { + showFilePickerLegacy(args: args) + } + } + + @objc public func saveFileDialog(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(SaveFileDialogOptions.self) + + // The Tauri save dialog API prompts the user to select a path where a file must be saved + // This behavior maps to the operating system interfaces on all platforms except iOS, + // which only exposes a mechanism to "move file `srcPath` to a location defined by the user" + // + // so we have to work around it by creating an empty file matching the requested `args.fileName`, + // and using it as `srcPath` for the operation - returning the path the user selected + // so the app dev can write to it later - matching cross platform behavior as mentioned above + let fileManager = FileManager.default + let srcFolder = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let srcPath = srcFolder.appendingPathComponent(args.fileName ?? "file") + if !fileManager.fileExists(atPath: srcPath.path) { + // the file contents must be actually provided by the tauri dev after the path is resolved by the save API + try "".write(to: srcPath, atomically: true, encoding: .utf8) + } + + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["file": urls.first!]) + case .cancelled: + invoke.resolve(["file": nil]) + case .error(let error): + invoke.reject(error) + } + } + + DispatchQueue.main.async { + let picker = UIDocumentPickerViewController(url: srcPath, in: .exportToService) + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + picker.delegate = self.filePickerController + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + + private func presentViewController(_ viewControllerToPresent: UIViewController) { + self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) + } + + @available(iOS 14, *) + private func parseFiltersOption(_ filters: [Filter]) -> [UTType] { + var parsedTypes: [UTType] = [] + for filter in filters { + for ext in filter.extensions ?? [] { + // We need to support extensions as well as MIME types. + if let utType = UTType(mimeType: ext) { + parsedTypes.append(utType) + } else if let utType = UTType(filenameExtension: ext) { + parsedTypes.append(utType) + } + } + } + + return parsedTypes + } + + /// This function is only used for iOS < 14, and should be removed if/when the deployment target is raised to 14. + private func showFilePickerLegacy(args: FilePickerOptions) { + let parsedTypes = parseFiltersOptionLegacy(args.filters ?? []) + + var filtersIncludeImage: Bool = false + var filtersIncludeVideo: Bool = false + var filtersIncludeNonMedia: Bool = false + + if !parsedTypes.isEmpty { + let mimeKinds = Set(parsedTypes.map { $0.components(separatedBy: "/")[0] }) + filtersIncludeImage = mimeKinds.contains("image") + filtersIncludeVideo = mimeKinds.contains("video") + filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" }) + } + + if !filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo) { + DispatchQueue.main.async { + let picker = UIImagePickerController() + picker.delegate = self.filePickerController + + if filtersIncludeImage && !filtersIncludeVideo { + picker.sourceType = .photoLibrary + } + + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } else { + let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes + DispatchQueue.main.async { + let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + + picker.delegate = self.filePickerController + picker.allowsMultipleSelection = args.multiple ?? false + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + } + + /// This function is only used for iOS < 14, and should be removed if/when the deployment target is raised to 14. + private func parseFiltersOptionLegacy(_ filters: [Filter]) -> [String] { + var parsedTypes: [String] = [] + for filter in filters { + for ext in filter.extensions ?? [] { + guard + let utType: String = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String? + else { + continue + } + parsedTypes.append(utType) + } + } + + return parsedTypes + } + + public func onFilePickerEvent(_ event: FilePickerEvent) { + self.onFilePickerResult?(event) + } + + @objc public func showMessageDialog(_ invoke: Invoke) throws { + let manager = self.manager + let args = try invoke.parseArgs(MessageDialogOptions.self) + + DispatchQueue.main.async { [] in + let alert = UIAlertController( + title: args.title, message: args.message, preferredStyle: UIAlertController.Style.alert) + + if let cancelButtonLabel = args.cancelButtonLabel { + alert.addAction( + UIAlertAction( + title: cancelButtonLabel, style: UIAlertAction.Style.default, + handler: { (_) -> Void in + invoke.resolve(["value": cancelButtonLabel]) + } + ) + ) + } + + if let noButtonLabel = args.noButtonLabel { + alert.addAction( + UIAlertAction( + title: noButtonLabel, style: UIAlertAction.Style.default, + handler: { (_) -> Void in + invoke.resolve(["value": noButtonLabel]) + } + ) + ) + } + + let okButtonLabel = args.okButtonLabel ?? "Ok" + alert.addAction( + UIAlertAction( + title: okButtonLabel, style: UIAlertAction.Style.default, + handler: { (_) -> Void in + invoke.resolve(["value": okButtonLabel]) + } + ) + ) + + manager.viewController?.present(alert, animated: true, completion: nil) + } + } + +} + +@_cdecl("init_plugin_dialog") +func initPlugin() -> Plugin { + return DialogPlugin() +} diff --git a/plugins/dialog/ios/Sources/FilePickerController.swift b/plugins/dialog/ios/Sources/FilePickerController.swift new file mode 100644 index 0000000..99cbb4b --- /dev/null +++ b/plugins/dialog/ios/Sources/FilePickerController.swift @@ -0,0 +1,253 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import UIKit +import MobileCoreServices +import PhotosUI +import Photos +import Tauri + +public class FilePickerController: NSObject { + var plugin: DialogPlugin + + init(_ dialogPlugin: DialogPlugin) { + plugin = dialogPlugin + } + + private func dismissViewController(_ viewControllerToPresent: UIViewController, completion: (() -> Void)? = nil) { + viewControllerToPresent.dismiss(animated: true, completion: completion) + } + + public func getModifiedAtFromUrl(_ url: URL) -> Int? { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let modifiedDateInSec = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 { + return Int(modifiedDateInSec * 1000.0) + } else { + return nil + } + } catch let error as NSError { + Logger.error("getModifiedAtFromUrl failed", error.localizedDescription) + return nil + } + } + + public func getMimeTypeFromUrl(_ url: URL) -> String { + let fileExtension = url.pathExtension as CFString + guard let extUTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension, nil)?.takeUnretainedValue() else { + return "" + } + guard let mimeUTI = UTTypeCopyPreferredTagWithClass(extUTI, kUTTagClassMIMEType) else { + return "" + } + return mimeUTI.takeRetainedValue() as String + } + + public func getSizeFromUrl(_ url: URL) throws -> Int { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + return values.fileSize ?? 0 + } + + public func getVideoDuration(_ url: URL) -> Int { + let asset = AVAsset(url: url) + let duration = asset.duration + let durationTime = CMTimeGetSeconds(duration) + return Int(round(durationTime)) + } + + public func getImageDimensions(_ url: URL) -> (Int?, Int?) { + if let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) { + if let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as Dictionary? { + return getHeightAndWidthFromImageProperties(imageProperties) + } + } + return (nil, nil) + } + + public func getVideoDimensions(_ url: URL) -> (Int?, Int?) { + guard let track = AVURLAsset(url: url).tracks(withMediaType: AVMediaType.video).first else { return (nil, nil) } + let size = track.naturalSize.applying(track.preferredTransform) + let height = abs(Int(size.height)) + let width = abs(Int(size.width)) + return (height, width) + } + + private func getHeightAndWidthFromImageProperties(_ properties: [NSObject: AnyObject]) -> (Int?, Int?) { + let width = properties[kCGImagePropertyPixelWidth] as? Int + let height = properties[kCGImagePropertyPixelHeight] as? Int + let orientation = properties[kCGImagePropertyOrientation] as? Int ?? UIImage.Orientation.up.rawValue + switch orientation { + case UIImage.Orientation.left.rawValue, UIImage.Orientation.right.rawValue, UIImage.Orientation.leftMirrored.rawValue, UIImage.Orientation.rightMirrored.rawValue: + return (width, height) + default: + return (height, width) + } + } + + private func getFileUrlByPath(_ path: String) -> URL? { + guard let url = URL.init(string: path) else { + return nil + } + if FileManager.default.fileExists(atPath: url.path) { + return url + } else { + return nil + } + } + + /// ## In which cases do we need to save a copy of a file selected by a user to the app sandbox? + /// In short, only when the file is **not** selected using UIDocumentPickerDelegate. + /// For the rest of the cases, we need to write a copy of the file to the app sandbox. + /// + /// For PHPicker (used for photos and videos), `NSItemProvider.loadFileRepresentation` returns a temporary file URL that is deleted after the completion handler. + /// The recommendation is to [Persist](https://developer.apple.com/documentation/foundation/nsitemprovider/2888338-loadfilerepresentation) the file by moving/copying + /// it to your app’s directory within the completion handler. + /// + /// If available, `loadInPlaceFileRepresentation` can open a file in place; Photos assets typically do not support true in-place access, + /// so fall back to persisting a local file. + /// Ref: https://developer.apple.com/documentation/foundation/nsitemprovider/2888335-loadinplacefilerepresentation + /// + /// For UIDocumentPicker, prefer "open in place" and avoid copying when possible. + /// Ref: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller + private func saveTemporaryFile(_ sourceUrl: URL) throws -> URL { + + var directory = URL(fileURLWithPath: NSTemporaryDirectory()) + if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { + directory = cachesDirectory + } + + let targetUrl = directory.appendingPathComponent(sourceUrl.lastPathComponent) + do { + try deleteFile(targetUrl) + } + + try FileManager.default.copyItem(at: sourceUrl, to: targetUrl) + return targetUrl + } + + private func deleteFile(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(atPath: url.path) + } + } +} + +extension FilePickerController: UIDocumentPickerDelegate { + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + do { + self.plugin.onFilePickerEvent(.selected(urls)) + } catch { + self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) + } + } + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + self.plugin.onFilePickerEvent(.cancelled) + } +} + +extension FilePickerController: UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverPresentationControllerDelegate { + public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + dismissViewController(picker) + self.plugin.onFilePickerEvent(.cancelled) + } + + public func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + self.plugin.onFilePickerEvent(.cancelled) + } + + public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.plugin.onFilePickerEvent(.cancelled) + } + + public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + dismissViewController(picker) { + if let url = info[.mediaURL] as? URL { + do { + let temporaryUrl = try self.saveTemporaryFile(url) + self.plugin.onFilePickerEvent(.selected([temporaryUrl])) + } catch { + self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) + } + } else { + self.plugin.onFilePickerEvent(.cancelled) + } + } + } +} + +@available(iOS 14, *) +extension FilePickerController: PHPickerViewControllerDelegate { + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismissViewController(picker) + if results.first == nil { + self.plugin.onFilePickerEvent(.cancelled) + return + } + var temporaryUrls: [URL] = [] + var errorMessage: String? + let dispatchGroup = DispatchGroup() + for result in results { + if errorMessage != nil { + break + } + if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + dispatchGroup.enter() + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier, completionHandler: { url, error in + defer { + dispatchGroup.leave() + } + if let error = error { + errorMessage = error.localizedDescription + return + } + guard let url = url else { + errorMessage = "Unknown error" + return + } + do { + // We have to make a copy of the file to the app sandbox here, as PHPicker returns an NSItemProvider with either an ephemeral file URL or content that is deleted after the completion handler. + // This is a different behavior from UIDocumentPicker, where the file can either be copied to the app sandbox or opened in place, and then accessed with `startAccessingSecurityScopedResource`. + let temporaryUrl = try self.saveTemporaryFile(url) + temporaryUrls.append(temporaryUrl) + } catch { + errorMessage = "Failed to create a temporary copy of the file" + } + }) + } else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + dispatchGroup.enter() + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier, completionHandler: { url, error in + defer { + dispatchGroup.leave() + } + if let error = error { + errorMessage = error.localizedDescription + return + } + guard let url = url else { + errorMessage = "Unknown error" + return + } + do { + // We have to make a copy of the file to the app sandbox here, as PHPicker returns an NSItemProvider with either an ephemeral file URL or content that is deleted after the completion handler. + // This is a different behavior from UIDocumentPicker, where the file can either be copied to the app sandbox or opened in place, and then accessed with `startAccessingSecurityScopedResource`. + let temporaryUrl = try self.saveTemporaryFile(url) + temporaryUrls.append(temporaryUrl) + } catch { + errorMessage = "Failed to create a temporary copy of the file" + } + }) + } else { + errorMessage = "Unsupported file type identifier" + } + } + dispatchGroup.notify(queue: .main) { + if let errorMessage = errorMessage { + self.plugin.onFilePickerEvent(.error(errorMessage)) + return + } + self.plugin.onFilePickerEvent(.selected(temporaryUrls)) + } + } +} \ No newline at end of file diff --git a/plugins/dialog/ios/Tests/PluginTests/PluginTests.swift b/plugins/dialog/ios/Tests/PluginTests/PluginTests.swift new file mode 100644 index 0000000..99992ce --- /dev/null +++ b/plugins/dialog/ios/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import XCTest +@testable import ExamplePlugin + +final class ExamplePluginTests: XCTestCase { + func testExample() throws { + let plugin = ExamplePlugin() + } +} diff --git a/plugins/dialog/package.json b/plugins/dialog/package.json new file mode 100644 index 0000000..c7b8d59 --- /dev/null +++ b/plugins/dialog/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-dialog", + "version": "2.7.3", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/dialog/permissions/ask.toml b/plugins/dialog/permissions/ask.toml new file mode 100644 index 0000000..7f79e84 --- /dev/null +++ b/plugins/dialog/permissions/ask.toml @@ -0,0 +1,11 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "allow-ask" +description = "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" +commands.allow = ["message"] + +[[permission]] +identifier = "deny-ask" +description = "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" +commands.deny = ["message"] diff --git a/plugins/dialog/permissions/autogenerated/commands/message.toml b/plugins/dialog/permissions/autogenerated/commands/message.toml new file mode 100644 index 0000000..d386d91 --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/commands/message.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-message" +description = "Enables the message command without any pre-configured scope." +commands.allow = ["message"] + +[[permission]] +identifier = "deny-message" +description = "Denies the message command without any pre-configured scope." +commands.deny = ["message"] diff --git a/plugins/dialog/permissions/autogenerated/commands/open.toml b/plugins/dialog/permissions/autogenerated/commands/open.toml new file mode 100644 index 0000000..4ea6dff --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/commands/open.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-open" +description = "Enables the open command without any pre-configured scope." +commands.allow = ["open"] + +[[permission]] +identifier = "deny-open" +description = "Denies the open command without any pre-configured scope." +commands.deny = ["open"] diff --git a/plugins/dialog/permissions/autogenerated/commands/save.toml b/plugins/dialog/permissions/autogenerated/commands/save.toml new file mode 100644 index 0000000..d3e8422 --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/commands/save.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-save" +description = "Enables the save command without any pre-configured scope." +commands.allow = ["save"] + +[[permission]] +identifier = "deny-save" +description = "Denies the save command without any pre-configured scope." +commands.deny = ["save"] diff --git a/plugins/dialog/permissions/autogenerated/reference.md b/plugins/dialog/permissions/autogenerated/reference.md new file mode 100644 index 0000000..b49897d --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/reference.md @@ -0,0 +1,154 @@ +## Default Permission + +This permission set configures the types of dialogs +available from the dialog plugin. + +#### Granted Permissions + +All dialog types are enabled. + +#### This default permission set includes the following: + +- `allow-message` +- `allow-save` +- `allow-open` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`dialog:allow-ask` + + + +Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3) + +
+ +`dialog:deny-ask` + + + +Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3) + +
+ +`dialog:allow-message` + + + +Enables the message command without any pre-configured scope. + +
+ +`dialog:deny-message` + + + +Denies the message command without any pre-configured scope. + +
+ +`dialog:allow-open` + + + +Enables the open command without any pre-configured scope. + +
+ +`dialog:deny-open` + + + +Denies the open command without any pre-configured scope. + +
+ +`dialog:allow-save` + + + +Enables the save command without any pre-configured scope. + +
+ +`dialog:deny-save` + + + +Denies the save command without any pre-configured scope. + +
+ +`dialog:allow-confirm` + + + +Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3) + +
+ +`dialog:deny-confirm` + + + +Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3) + +
diff --git a/plugins/dialog/permissions/confirm.toml b/plugins/dialog/permissions/confirm.toml new file mode 100644 index 0000000..b03a59b --- /dev/null +++ b/plugins/dialog/permissions/confirm.toml @@ -0,0 +1,11 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "allow-confirm" +description = "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" +commands.allow = ["message"] + +[[permission]] +identifier = "deny-confirm" +description = "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" +commands.deny = ["message"] diff --git a/plugins/dialog/permissions/default.toml b/plugins/dialog/permissions/default.toml new file mode 100644 index 0000000..181e81d --- /dev/null +++ b/plugins/dialog/permissions/default.toml @@ -0,0 +1,14 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures the types of dialogs +available from the dialog plugin. + +#### Granted Permissions + +All dialog types are enabled. + + +""" +permissions = ["allow-message", "allow-save", "allow-open"] diff --git a/plugins/dialog/permissions/schemas/schema.json b/plugins/dialog/permissions/schemas/schema.json new file mode 100644 index 0000000..75d98d0 --- /dev/null +++ b/plugins/dialog/permissions/schemas/schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/dialog/rollup.config.js b/plugins/dialog/rollup.config.js new file mode 100644 index 0000000..a7dbd4f --- /dev/null +++ b/plugins/dialog/rollup.config.js @@ -0,0 +1,22 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' +import terser from '@rollup/plugin-terser' + +export default createConfig({ + additionalConfigs: { + input: 'guest-js/init.ts', + output: { + file: 'src/init-iife.js', + format: 'iife' + }, + plugins: [typescript(), terser(), nodeResolve()], + onwarn: (warning) => { + throw Object.assign(new Error(), warning) + } + } +}) diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs new file mode 100644 index 0000000..2564a1a --- /dev/null +++ b/plugins/dialog/src/commands.rs @@ -0,0 +1,290 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::{command, Manager, Runtime, State, Window}; +use tauri_plugin_fs::FsExt; + +use crate::{ + Dialog, FileAccessMode, FileDialogBuilder, FilePath, MessageDialogButtons, MessageDialogKind, + MessageDialogResult, PickerMode, Result, +}; + +#[derive(Serialize)] +#[serde(untagged)] +pub enum OpenResponse { + #[cfg(desktop)] + Folders(Option>), + #[cfg(desktop)] + Folder(Option), + Files(Option>), + File(Option), +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DialogFilter { + name: String, + extensions: Vec, +} + +/// The options for the open dialog API. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenDialogOptions { + /// The title of the dialog window. + title: Option, + /// The filters of the dialog. + #[serde(default)] + filters: Vec, + /// Whether the dialog allows multiple selection or not. + #[serde(default)] + multiple: bool, + /// Whether the dialog is a directory selection (`true` value) or file selection (`false` value). + #[serde(default)] + directory: bool, + /// The initial path of the dialog. + default_path: Option, + /// If [`Self::directory`] is true, indicates that it will be read recursively later. + /// Defines whether subdirectories will be allowed on the scope or not. + #[serde(default)] + #[cfg_attr(mobile, allow(dead_code))] + recursive: bool, + /// Whether to allow creating directories in the dialog **macOS Only** + can_create_directories: Option, + /// The preferred mode of the dialog. + /// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers. + /// On desktop, this option is ignored. + /// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters. + #[serde(default)] + #[cfg_attr(mobile, allow(dead_code))] + picker_mode: Option, + /// The file access mode of the dialog. + #[serde(default)] + #[cfg_attr(mobile, allow(dead_code))] + file_access_mode: Option, +} + +/// The options for the save dialog API. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(mobile, allow(dead_code))] +pub struct SaveDialogOptions { + /// The title of the dialog window. + title: Option, + /// The filters of the dialog. + #[serde(default)] + filters: Vec, + /// The initial path of the dialog. + default_path: Option, + /// Whether to allow creating directories in the dialog **macOS Only** + can_create_directories: Option, +} + +#[cfg(mobile)] +fn set_default_path( + mut dialog_builder: FileDialogBuilder, + default_path: PathBuf, +) -> FileDialogBuilder { + if let Some(file_name) = default_path.file_name() { + dialog_builder = dialog_builder.set_file_name(file_name.to_string_lossy()); + } + dialog_builder +} + +#[cfg(desktop)] +fn set_default_path( + mut dialog_builder: FileDialogBuilder, + default_path: PathBuf, +) -> FileDialogBuilder { + // we need to adjust the separator on Windows: https://github.com/tauri-apps/tauri/issues/8074 + let default_path: PathBuf = default_path.components().collect(); + if default_path.is_file() || !default_path.exists() { + if let (Some(parent), Some(file_name)) = (default_path.parent(), default_path.file_name()) { + if parent.components().count() > 0 { + dialog_builder = dialog_builder.set_directory(parent); + } + dialog_builder = dialog_builder.set_file_name(file_name.to_string_lossy()); + } else { + dialog_builder = dialog_builder.set_directory(default_path); + } + dialog_builder + } else { + dialog_builder.set_directory(default_path) + } +} + +#[command] +pub(crate) async fn open( + window: Window, + dialog: State<'_, Dialog>, + options: OpenDialogOptions, +) -> Result { + let mut dialog_builder = dialog.file(); + #[cfg(any(windows, target_os = "macos"))] + { + dialog_builder = dialog_builder.set_parent(&window); + } + if let Some(title) = options.title { + dialog_builder = dialog_builder.set_title(title); + } + if let Some(default_path) = options.default_path { + dialog_builder = set_default_path(dialog_builder, default_path); + } + if let Some(can) = options.can_create_directories { + dialog_builder = dialog_builder.set_can_create_directories(can); + } + if let Some(picker_mode) = options.picker_mode { + dialog_builder = dialog_builder.set_picker_mode(picker_mode); + } + for filter in options.filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect(); + dialog_builder = dialog_builder.add_filter(filter.name, &extensions); + } + if let Some(file_access_mode) = options.file_access_mode { + dialog_builder = dialog_builder.set_file_access_mode(file_access_mode); + } + + let res = if options.directory { + #[cfg(desktop)] + { + let tauri_scope = window.state::(); + + if options.multiple { + let folders = dialog_builder.blocking_pick_folders(); + if let Some(folders) = &folders { + for folder in folders { + if let Ok(path) = folder.clone().into_path() { + if let Some(s) = window.try_fs_scope() { + s.allow_directory(&path, options.recursive)?; + } + tauri_scope.allow_directory(&path, options.directory)?; + } + } + } + OpenResponse::Folders( + folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()), + ) + } else { + let folder = dialog_builder.blocking_pick_folder(); + if let Some(folder) = &folder { + if let Ok(path) = folder.clone().into_path() { + if let Some(s) = window.try_fs_scope() { + s.allow_directory(&path, options.recursive)?; + } + tauri_scope.allow_directory(&path, options.directory)?; + } + } + OpenResponse::Folder(folder.map(|p| p.simplified())) + } + } + #[cfg(mobile)] + return Err(crate::Error::FolderPickerNotImplemented); + } else if options.multiple { + let tauri_scope = window.state::(); + + let files = dialog_builder.blocking_pick_files(); + if let Some(files) = &files { + for file in files { + if let Ok(path) = file.clone().into_path() { + if let Some(s) = window.try_fs_scope() { + s.allow_file(&path)?; + } + + tauri_scope.allow_file(&path)?; + } + } + } + OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect())) + } else { + let tauri_scope = window.state::(); + let file = dialog_builder.blocking_pick_file(); + + if let Some(file) = &file { + if let Ok(path) = file.clone().into_path() { + if let Some(s) = window.try_fs_scope() { + s.allow_file(&path)?; + } + tauri_scope.allow_file(&path)?; + } + } + OpenResponse::File(file.map(|f| f.simplified())) + }; + Ok(res) +} + +#[allow(unused_variables)] +#[command] +pub(crate) async fn save( + window: Window, + dialog: State<'_, Dialog>, + options: SaveDialogOptions, +) -> Result> { + let mut dialog_builder = dialog.file(); + #[cfg(desktop)] + { + dialog_builder = dialog_builder.set_parent(&window); + } + if let Some(title) = options.title { + dialog_builder = dialog_builder.set_title(title); + } + if let Some(default_path) = options.default_path { + dialog_builder = set_default_path(dialog_builder, default_path); + } + if let Some(can) = options.can_create_directories { + dialog_builder = dialog_builder.set_can_create_directories(can); + } + for filter in options.filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect(); + dialog_builder = dialog_builder.add_filter(filter.name, &extensions); + } + + let tauri_scope = window.state::(); + + let path = dialog_builder.blocking_save_file(); + if let Some(p) = &path { + if let Ok(path) = p.clone().into_path() { + if let Some(s) = window.try_fs_scope() { + s.allow_file(&path)?; + } + tauri_scope.allow_file(&path)?; + } + } + + Ok(path.map(|p| p.simplified())) +} + +#[command] +pub(crate) async fn message( + #[allow(unused)] window: Window, + dialog: State<'_, Dialog>, + title: Option, + message: String, + kind: Option, + buttons: Option, +) -> Result { + let mut builder = dialog.message(message); + + if let Some(buttons) = buttons { + builder = builder.buttons(buttons); + } + + if let Some(title) = title { + builder = builder.title(title); + } + + #[cfg(desktop)] + { + builder = builder.parent(&window); + } + + if let Some(kind) = kind { + builder = builder.kind(kind); + } + + Ok(builder.blocking_show_with_result()) +} diff --git a/plugins/dialog/src/desktop.rs b/plugins/dialog/src/desktop.rs new file mode 100644 index 0000000..8d3b08f --- /dev/null +++ b/plugins/dialog/src/desktop.rs @@ -0,0 +1,257 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Use native message and file open/save dialogs. +//! +//! This module exposes non-blocking APIs on its root, relying on callback closures +//! to give results back. This is particularly useful when running dialogs from the main thread. +//! When using on asynchronous contexts such as async commands, the [`blocking`] APIs are recommended. + +use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}; +use rfd::{AsyncFileDialog, AsyncMessageDialog}; +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::{models::*, FileDialogBuilder, FilePath, MessageDialogBuilder}; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Dialog(app.clone())) +} + +/// Access to the dialog APIs. +#[derive(Debug)] +pub struct Dialog(AppHandle); + +impl Clone for Dialog { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Dialog { + pub(crate) fn app_handle(&self) -> &AppHandle { + &self.0 + } +} + +impl From for rfd::MessageLevel { + fn from(kind: MessageDialogKind) -> Self { + match kind { + MessageDialogKind::Info => Self::Info, + MessageDialogKind::Warning => Self::Warning, + MessageDialogKind::Error => Self::Error, + } + } +} + +#[derive(Debug)] +pub(crate) struct WindowHandle { + window_handle: RawWindowHandle, + display_handle: RawDisplayHandle, +} + +impl WindowHandle { + pub(crate) fn new(window_handle: RawWindowHandle, display_handle: RawDisplayHandle) -> Self { + Self { + window_handle, + display_handle, + } + } +} + +impl HasWindowHandle for WindowHandle { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(self.window_handle) }) + } +} + +impl HasDisplayHandle for WindowHandle { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(unsafe { raw_window_handle::DisplayHandle::borrow_raw(self.display_handle) }) + } +} + +impl From> for AsyncFileDialog { + fn from(d: FileDialogBuilder) -> Self { + let mut builder = AsyncFileDialog::new(); + + if let Some(title) = d.title { + builder = builder.set_title(title); + } + if let Some(starting_directory) = d.starting_directory { + builder = builder.set_directory(starting_directory); + } + if let Some(file_name) = d.file_name { + builder = builder.set_file_name(file_name); + } + for filter in d.filters { + let v: Vec<&str> = filter.extensions.iter().map(|x| &**x).collect(); + builder = builder.add_filter(&filter.name, &v); + } + #[cfg(desktop)] + if let Some(parent) = d.parent { + builder = builder.set_parent(&parent); + } + + builder = builder.set_can_create_directories(d.can_create_directories.unwrap_or(true)); + + builder + } +} + +impl From for rfd::MessageButtons { + fn from(value: MessageDialogButtons) -> Self { + match value { + MessageDialogButtons::Ok => Self::Ok, + MessageDialogButtons::OkCancel => Self::OkCancel, + MessageDialogButtons::YesNo => Self::YesNo, + MessageDialogButtons::OkCustom(ok) => Self::OkCustom(ok), + MessageDialogButtons::OkCancelCustom(ok, cancel) => Self::OkCancelCustom(ok, cancel), + MessageDialogButtons::YesNoCancel => Self::YesNoCancel, + MessageDialogButtons::YesNoCancelCustom(yes, no, cancel) => { + Self::YesNoCancelCustom(yes, no, cancel) + } + } + } +} + +impl From> for AsyncMessageDialog { + fn from(d: MessageDialogBuilder) -> Self { + let mut dialog = AsyncMessageDialog::new() + .set_title(&d.title) + .set_description(&d.message) + .set_level(d.kind.into()) + .set_buttons(d.buttons.into()); + + if let Some(parent) = d.parent { + dialog = dialog.set_parent(&parent); + } + + dialog + } +} + +pub fn pick_file) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + let f = |path: Option| f(path.map(|p| p.path().to_path_buf().into())); + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let dialog = AsyncFileDialog::from(dialog).pick_file(); + std::thread::spawn(move || f(tauri::async_runtime::block_on(dialog))); + }); +} + +pub fn pick_files>) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + let f = |paths: Option>| { + f(paths.map(|list| { + list.into_iter() + .map(|p| p.path().to_path_buf().into()) + .collect() + })) + }; + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let dialog = AsyncFileDialog::from(dialog).pick_files(); + std::thread::spawn(move || f(tauri::async_runtime::block_on(dialog))); + }); +} + +pub fn pick_folder) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + let f = |path: Option| f(path.map(|p| p.path().to_path_buf().into())); + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let dialog = AsyncFileDialog::from(dialog).pick_folder(); + std::thread::spawn(move || f(tauri::async_runtime::block_on(dialog))); + }); +} + +pub fn pick_folders>) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + let f = |paths: Option>| { + f(paths.map(|list| { + list.into_iter() + .map(|p| p.path().to_path_buf().into()) + .collect() + })) + }; + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let dialog = AsyncFileDialog::from(dialog).pick_folders(); + std::thread::spawn(move || f(tauri::async_runtime::block_on(dialog))); + }); +} + +pub fn save_file) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + let f = |path: Option| f(path.map(|p| p.path().to_path_buf().into())); + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let dialog = AsyncFileDialog::from(dialog).save_file(); + std::thread::spawn(move || f(tauri::async_runtime::block_on(dialog))); + }); +} + +/// Shows a message dialog +pub fn show_message_dialog( + dialog: MessageDialogBuilder, + callback: F, +) { + let f = move |res: rfd::MessageDialogResult| callback(res.into()); + + let handle = dialog.dialog.app_handle().to_owned(); + let _ = handle.run_on_main_thread(move || { + let buttons = dialog.buttons.clone(); + let dialog = AsyncMessageDialog::from(dialog).show(); + std::thread::spawn(move || { + let result = tauri::async_runtime::block_on(dialog); + // on Linux rfd does not return rfd::MessageDialogResult::Custom, so we must map manually + let result = match (result, buttons) { + (rfd::MessageDialogResult::Ok, MessageDialogButtons::OkCustom(s)) => { + rfd::MessageDialogResult::Custom(s) + } + ( + rfd::MessageDialogResult::Ok, + MessageDialogButtons::OkCancelCustom(ok, _cancel), + ) => rfd::MessageDialogResult::Custom(ok), + ( + rfd::MessageDialogResult::Cancel, + MessageDialogButtons::OkCancelCustom(_ok, cancel), + ) => rfd::MessageDialogResult::Custom(cancel), + ( + rfd::MessageDialogResult::Yes, + MessageDialogButtons::YesNoCancelCustom(yes, _no, _cancel), + ) => rfd::MessageDialogResult::Custom(yes), + ( + rfd::MessageDialogResult::No, + MessageDialogButtons::YesNoCancelCustom(_yes, no, _cancel), + ) => rfd::MessageDialogResult::Custom(no), + ( + rfd::MessageDialogResult::Cancel, + MessageDialogButtons::YesNoCancelCustom(_yes, _no, cancel), + ) => rfd::MessageDialogResult::Custom(cancel), + (result, _) => result, + }; + f(result); + }); + }); +} diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs new file mode 100644 index 0000000..dac05b1 --- /dev/null +++ b/plugins/dialog/src/error.rs @@ -0,0 +1,40 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for `Result` used throughout this crate. +pub type Result = std::result::Result; + +/// Errors that can occur while showing or interacting with a dialog. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// An error forwarded from the Tauri core. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// An I/O error, for example while resolving a picked path. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Forwarding a request to, or receiving a response from, the mobile plugin failed. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The folder picker was requested through the `open` command, but folder picking is not implemented on mobile. + #[cfg(mobile)] + #[error("Folder picker is not implemented on mobile")] + FolderPickerNotImplemented, + /// An error forwarded from the `fs` plugin, returned when granting filesystem scope to a picked path fails. + #[error(transparent)] + Fs(#[from] tauri_plugin_fs::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/dialog/src/init-iife.js b/plugins/dialog/src/init-iife.js new file mode 100644 index 0000000..bcdf3f5 --- /dev/null +++ b/plugins/dialog/src/init-iife.js @@ -0,0 +1 @@ +!function(){"use strict";async function n(n,i={},o){return window.__TAURI_INTERNALS__.invoke(n,i,o)}"function"==typeof SuppressedError&&SuppressedError,window.alert=function(i){n("plugin:dialog|message",{message:i.toString()})},window.confirm=async function(i){return await n("plugin:dialog|confirm",{message:i.toString()})}}(); diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs new file mode 100644 index 0000000..0e092af --- /dev/null +++ b/plugins/dialog/src/lib.rs @@ -0,0 +1,786 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Native system dialogs for opening and saving files along with message dialogs. +//! +//! ## Cargo features +//! +//! - **gtk3** *(enabled by default)*: Uses GTK for dialogs on Linux & BSDs; has no effect on Windows and macOS +//! - **xdg-portal**: Uses XDG Desktop Portal instead of GTK on Linux & BSDs + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +use std::{ + path::{Path, PathBuf}, + sync::mpsc::sync_channel, +}; + +pub use models::*; + +pub use tauri_plugin_fs::FilePath; +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +use desktop::*; +#[cfg(mobile)] +use mobile::*; + +#[cfg(desktop)] +pub use desktop::Dialog; +#[cfg(mobile)] +pub use mobile::Dialog; + +/// The preferred mode of the file picker on mobile platforms (iOS and Android), which have +/// distinct file and media pickers. On desktop, this option is ignored. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum PickerMode { + /// Show the generic document picker. + Document, + /// Show the media picker, allowing both images and videos to be selected. + Media, + /// Show the media picker restricted to images. + Image, + /// Show the media picker restricted to videos. + Video, +} + +/// The file access mode of the dialog, used to control how a picked file is exposed to the app on iOS. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum FileAccessMode { + /// Copy the picked file into the app's sandbox so it can be freely read, edited or deleted. + Copy, + /// Keep the file at its original location and let the system manage security-scoped access to it. + Scoped, +} + +pub(crate) const OK: &str = "Ok"; +#[cfg(mobile)] +pub(crate) const CANCEL: &str = "Cancel"; +#[cfg(mobile)] +pub(crate) const YES: &str = "Yes"; +#[cfg(mobile)] +pub(crate) const NO: &str = "No"; + +macro_rules! blocking_fn { + ($self:ident, $fn:ident) => {{ + let (tx, rx) = sync_channel(0); + let cb = move |response| { + tx.send(response).unwrap(); + }; + $self.$fn(cb); + rx.recv().unwrap() + }}; +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the dialog APIs. +pub trait DialogExt { + /// Returns the [`Dialog`] instance associated with this app/window. + fn dialog(&self) -> &Dialog; +} + +impl> crate::DialogExt for T { + fn dialog(&self) -> &Dialog { + self.state::>().inner() + } +} + +impl Dialog { + /// Create a new messaging dialog builder. + /// The dialog can optionally ask the user for confirmation or include an OK button. + /// + /// # Examples + /// + /// - Message dialog: + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// app + /// .dialog() + /// .message("Tauri is Awesome!") + /// .show(|_| { + /// println!("dialog closed"); + /// }); + /// Ok(()) + /// }); + /// ``` + /// + /// - Ask dialog: + /// + /// ```no_run + /// use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog() + /// .message("Are you sure?") + /// .buttons(MessageDialogButtons::OkCancelCustom("Yes".to_string(), "No".to_string())) + /// .show(|yes| { + /// println!("user said {}", if yes { "yes" } else { "no" }); + /// }); + /// Ok(()) + /// }); + /// ``` + /// + /// - Message dialog with OK button: + /// + /// ```no_run + /// use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog() + /// .message("Job completed successfully") + /// .buttons(MessageDialogButtons::Ok) + /// .show(|_| { + /// println!("dialog closed"); + /// }); + /// Ok(()) + /// }); + /// ``` + /// + /// # `show` vs `blocking_show` + /// + /// The dialog builder includes two separate APIs for rendering the dialog: `show` and `blocking_show`. + /// The `show` function is asynchronous and takes a closure to be executed when the dialog is closed. + /// To block the current thread until the user acted on the dialog, you can use `blocking_show`, + /// but note that it cannot be executed on the main thread as it will freeze your application. + /// + /// ```no_run + /// use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// std::thread::spawn(move || { + /// let yes = handle.dialog() + /// .message("Are you sure?") + /// .buttons(MessageDialogButtons::OkCancelCustom("Yes".to_string(), "No".to_string())) + /// .blocking_show(); + /// }); + /// + /// Ok(()) + /// }); + /// ``` + pub fn message(&self, message: impl Into) -> MessageDialogBuilder { + MessageDialogBuilder::new( + self.clone(), + self.app_handle().package_info().name.clone(), + message, + ) + } + + /// Creates a new builder for dialogs that lets the user select file(s) or folder(s). + pub fn file(&self) -> FileDialogBuilder { + FileDialogBuilder::new(self.clone()) + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + #[allow(unused_mut)] + let mut builder = Builder::new("dialog"); + + // Dialogs are implemented natively on Android + #[cfg(not(target_os = "android"))] + { + builder = builder.js_init_script(include_str!("init-iife.js").to_string()); + } + + builder + .invoke_handler(tauri::generate_handler![ + commands::open, + commands::save, + commands::message, + ]) + .setup(|app, api| { + #[cfg(mobile)] + let dialog = mobile::init(app, api)?; + #[cfg(desktop)] + let dialog = desktop::init(app, api)?; + app.manage(dialog); + Ok(()) + }) + .build() +} + +/// A builder for message dialogs. +pub struct MessageDialogBuilder { + #[allow(dead_code)] + pub(crate) dialog: Dialog, + pub(crate) title: String, + pub(crate) message: String, + pub(crate) kind: MessageDialogKind, + pub(crate) buttons: MessageDialogButtons, + #[cfg(desktop)] + pub(crate) parent: Option, +} + +/// Payload for the message dialog mobile API. +#[cfg(mobile)] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MessageDialogPayload<'a> { + title: &'a String, + message: &'a String, + kind: &'a MessageDialogKind, + ok_button_label: Option<&'a str>, + no_button_label: Option<&'a str>, + cancel_button_label: Option<&'a str>, +} + +// raw window handle :( +unsafe impl Send for MessageDialogBuilder {} + +impl MessageDialogBuilder { + /// Creates a new message dialog builder. + pub fn new(dialog: Dialog, title: impl Into, message: impl Into) -> Self { + Self { + dialog, + title: title.into(), + message: message.into(), + kind: MessageDialogKind::default(), + buttons: MessageDialogButtons::default(), + #[cfg(desktop)] + parent: None, + } + } + + #[cfg(mobile)] + pub(crate) fn payload(&self) -> MessageDialogPayload<'_> { + let (ok_button_label, no_button_label, cancel_button_label) = match &self.buttons { + MessageDialogButtons::Ok => (Some(OK), None, None), + MessageDialogButtons::OkCancel => (Some(OK), None, Some(CANCEL)), + MessageDialogButtons::YesNo => (Some(YES), Some(NO), None), + MessageDialogButtons::YesNoCancel => (Some(YES), Some(NO), Some(CANCEL)), + MessageDialogButtons::OkCustom(ok) => (Some(ok.as_str()), None, None), + MessageDialogButtons::OkCancelCustom(ok, cancel) => { + (Some(ok.as_str()), None, Some(cancel.as_str())) + } + MessageDialogButtons::YesNoCancelCustom(yes, no, cancel) => { + (Some(yes.as_str()), Some(no.as_str()), Some(cancel.as_str())) + } + }; + MessageDialogPayload { + title: &self.title, + message: &self.message, + kind: &self.kind, + ok_button_label, + no_button_label, + cancel_button_label, + } + } + + /// Sets the dialog title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Set parent windows explicitly (optional) + #[cfg(desktop)] + pub fn parent( + mut self, + parent: &W, + ) -> Self { + if let (Ok(window_handle), Ok(display_handle)) = + (parent.window_handle(), parent.display_handle()) + { + self.parent.replace(crate::desktop::WindowHandle::new( + window_handle.as_raw(), + display_handle.as_raw(), + )); + } + self + } + + /// Sets the dialog buttons. + pub fn buttons(mut self, buttons: MessageDialogButtons) -> Self { + self.buttons = buttons; + self + } + + /// Set type of a dialog. + /// + /// Depending on the system it can result in type specific icon to show up, + /// the will inform user it message is a error, warning or just information. + pub fn kind(mut self, kind: MessageDialogKind) -> Self { + self.kind = kind; + self + } + + /// Shows a message dialog + /// + /// Returns `true` if the user pressed the OK/Yes button, + pub fn show(self, f: F) { + let ok_label = match &self.buttons { + MessageDialogButtons::OkCustom(ok) => Some(ok.clone()), + MessageDialogButtons::OkCancelCustom(ok, _) => Some(ok.clone()), + MessageDialogButtons::YesNoCancelCustom(yes, _, _) => Some(yes.clone()), + _ => None, + }; + + show_message_dialog(self, move |res| { + let sucess = match res { + MessageDialogResult::Ok | MessageDialogResult::Yes => true, + MessageDialogResult::Custom(s) => { + ok_label.map_or(s == OK, |ok_label| ok_label == s) + } + _ => false, + }; + + f(sucess) + }) + } + + /// Shows a message dialog and returns the button that was pressed. + /// + /// Returns a [`MessageDialogResult`] enum that indicates which button was pressed. + pub fn show_with_result(self, f: F) { + show_message_dialog(self, f) + } + + /// Shows a message dialog. + /// + /// Returns `true` if the user pressed the OK/Yes button, + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread context. + pub fn blocking_show(self) -> bool { + blocking_fn!(self, show) + } + + /// Shows a message dialog and returns the button that was pressed. + /// + /// Returns a [`MessageDialogResult`] enum that indicates which button was pressed. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread context. + pub fn blocking_show_with_result(self) -> MessageDialogResult { + blocking_fn!(self, show_with_result) + } +} +#[derive(Debug, Serialize)] +pub(crate) struct Filter { + pub name: String, + pub extensions: Vec, +} + +/// The file dialog builder. +/// +/// Constructs file picker dialogs that can select single/multiple files or directories. +#[derive(Debug)] +pub struct FileDialogBuilder { + #[allow(dead_code)] + pub(crate) dialog: Dialog, + pub(crate) filters: Vec, + pub(crate) starting_directory: Option, + pub(crate) file_name: Option, + pub(crate) title: Option, + pub(crate) can_create_directories: Option, + pub(crate) picker_mode: Option, + pub(crate) file_access_mode: Option, + #[cfg(desktop)] + pub(crate) parent: Option, +} + +#[cfg(mobile)] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileDialogPayload<'a> { + file_name: &'a Option, + filters: &'a Vec, + multiple: bool, + picker_mode: &'a Option, + file_access_mode: &'a Option, +} + +// raw window handle :( +unsafe impl Send for FileDialogBuilder {} + +impl FileDialogBuilder { + /// Gets the default file dialog builder. + pub fn new(dialog: Dialog) -> Self { + Self { + dialog, + filters: Vec::new(), + starting_directory: None, + file_name: None, + title: None, + can_create_directories: None, + picker_mode: None, + file_access_mode: None, + #[cfg(desktop)] + parent: None, + } + } + + #[cfg(mobile)] + pub(crate) fn payload(&self, multiple: bool) -> FileDialogPayload<'_> { + FileDialogPayload { + file_name: &self.file_name, + filters: &self.filters, + multiple, + picker_mode: &self.picker_mode, + file_access_mode: &self.file_access_mode, + } + } + + /// Add file extension filter. Takes in the name of the filter, and list of extensions + #[must_use] + pub fn add_filter(mut self, name: impl Into, extensions: &[&str]) -> Self { + self.filters.push(Filter { + name: name.into(), + extensions: extensions.iter().map(|e| e.to_string()).collect(), + }); + self + } + + /// Set starting directory of the dialog. + #[must_use] + pub fn set_directory>(mut self, directory: P) -> Self { + self.starting_directory.replace(directory.as_ref().into()); + self + } + + /// Set starting file name of the dialog. + #[must_use] + pub fn set_file_name(mut self, file_name: impl Into) -> Self { + self.file_name.replace(file_name.into()); + self + } + + /// Sets the parent window of the dialog. + #[cfg(desktop)] + #[must_use] + pub fn set_parent< + W: raw_window_handle::HasWindowHandle + raw_window_handle::HasDisplayHandle, + >( + mut self, + parent: &W, + ) -> Self { + if let (Ok(window_handle), Ok(display_handle)) = + (parent.window_handle(), parent.display_handle()) + { + self.parent.replace(crate::desktop::WindowHandle::new( + window_handle.as_raw(), + display_handle.as_raw(), + )); + } + self + } + + /// Set the title of the dialog. + #[must_use] + pub fn set_title(mut self, title: impl Into) -> Self { + self.title.replace(title.into()); + self + } + + /// Set whether it should be possible to create new directories in the dialog. Enabled by default. **macOS only**. + pub fn set_can_create_directories(mut self, can: bool) -> Self { + self.can_create_directories.replace(can); + self + } + + /// Set the picker mode of the dialog. + /// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers. + /// On desktop, this option is ignored. + /// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters. + pub fn set_picker_mode(mut self, mode: PickerMode) -> Self { + self.picker_mode.replace(mode); + self + } + + /// Set the file access mode of the dialog. + /// This is only used on iOS. + /// On desktop and Android, this option is ignored. + pub fn set_file_access_mode(mut self, mode: FileAccessMode) -> Self { + self.file_access_mode.replace(mode); + self + } + + /// Shows the dialog to select a single file. + /// + /// This is not a blocking operation, + /// and should be used when running on the main thread to avoid deadlocks with the event loop. + /// + /// See [`Self::blocking_pick_file`] for a blocking version for use in other contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog().file().pick_file(|file_path| { + /// // do something with the optional file path here + /// // the file path is `None` if the user closed the dialog + /// }); + /// Ok(()) + /// }); + /// ``` + pub fn pick_file) + Send + 'static>(self, f: F) { + pick_file(self, f) + } + + /// Shows the dialog to select multiple files. + /// + /// This is not a blocking operation, + /// and should be used when running on the main thread to avoid deadlocks with the event loop. + /// + /// See [`Self::blocking_pick_files`] for a blocking version for use in other contexts. + /// + /// # Reading the files + /// + /// The file paths cannot be read directly on Android as they are behind a content URI. + /// The recommended way to read the files is using the [`fs`](https://v2.tauri.app/plugin/file-system/) plugin: + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// use tauri_plugin_fs::FsExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// app.dialog().file().pick_file(move |file_path| { + /// let Some(path) = file_path else { return }; + /// let Ok(contents) = handle.fs().read_to_string(path) else { + /// eprintln!("failed to read file, "); + /// return; + /// }; + /// }); + /// Ok(()) + /// }); + /// ``` + /// + /// See for more information. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog().file().pick_files(|file_paths| { + /// // do something with the optional file paths here + /// // the file paths value is `None` if the user closed the dialog + /// }); + /// Ok(()) + /// }); + /// ``` + pub fn pick_files>) + Send + 'static>(self, f: F) { + pick_files(self, f) + } + + /// Shows the dialog to select a single folder. + /// + /// This is not a blocking operation, + /// and should be used when running on the main thread to avoid deadlocks with the event loop. + /// + /// See [`Self::blocking_pick_folder`] for a blocking version for use in other contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog().file().pick_folder(|folder_path| { + /// // do something with the optional folder path here + /// // the folder path is `None` if the user closed the dialog + /// }); + /// Ok(()) + /// }); + /// ``` + #[cfg(desktop)] + pub fn pick_folder) + Send + 'static>(self, f: F) { + pick_folder(self, f) + } + + /// Shows the dialog to select multiple folders. + /// + /// This is not a blocking operation, + /// and should be used when running on the main thread to avoid deadlocks with the event loop. + /// + /// See [`Self::blocking_pick_folders`] for a blocking version for use in other contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog().file().pick_folders(|file_paths| { + /// // do something with the optional folder paths here + /// // the folder paths value is `None` if the user closed the dialog + /// }); + /// Ok(()) + /// }); + /// ``` + #[cfg(desktop)] + pub fn pick_folders>) + Send + 'static>(self, f: F) { + pick_folders(self, f) + } + + /// Shows the dialog to save a file. + /// + /// This is not a blocking operation, + /// and should be used when running on the main thread to avoid deadlocks with the event loop. + /// + /// See [`Self::blocking_save_file`] for a blocking version for use in other contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.dialog().file().save_file(|file_path| { + /// // do something with the optional file path here + /// // the file path is `None` if the user closed the dialog + /// }); + /// Ok(()) + /// }); + /// ``` + pub fn save_file) + Send + 'static>(self, f: F) { + save_file(self, f) + } +} + +/// Blocking APIs. +impl FileDialogBuilder { + /// Shows the dialog to select a single file. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread. + /// + /// See [`Self::pick_file`] for a non-blocking version for use in main-thread contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// #[tauri::command] + /// async fn my_command(app: tauri::AppHandle) { + /// let file_path = app.dialog().file().blocking_pick_file(); + /// // do something with the optional file path here + /// // the file path is `None` if the user closed the dialog + /// } + /// ``` + pub fn blocking_pick_file(self) -> Option { + blocking_fn!(self, pick_file) + } + + /// Shows the dialog to select multiple files. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread. + /// + /// See [`Self::pick_files`] for a non-blocking version for use in main-thread contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// #[tauri::command] + /// async fn my_command(app: tauri::AppHandle) { + /// let file_path = app.dialog().file().blocking_pick_files(); + /// // do something with the optional file paths here + /// // the file paths value is `None` if the user closed the dialog + /// } + /// ``` + pub fn blocking_pick_files(self) -> Option> { + blocking_fn!(self, pick_files) + } + + /// Shows the dialog to select a single folder. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread. + /// + /// See [`Self::pick_folder`] for a non-blocking version for use in main-thread contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// #[tauri::command] + /// async fn my_command(app: tauri::AppHandle) { + /// let folder_path = app.dialog().file().blocking_pick_folder(); + /// // do something with the optional folder path here + /// // the folder path is `None` if the user closed the dialog + /// } + /// ``` + #[cfg(desktop)] + pub fn blocking_pick_folder(self) -> Option { + blocking_fn!(self, pick_folder) + } + + /// Shows the dialog to select multiple folders. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread. + /// + /// See [`Self::pick_folders`] for a non-blocking version for use in main-thread contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// #[tauri::command] + /// async fn my_command(app: tauri::AppHandle) { + /// let folder_paths = app.dialog().file().blocking_pick_folders(); + /// // do something with the optional folder paths here + /// // the folder paths value is `None` if the user closed the dialog + /// } + /// ``` + #[cfg(desktop)] + pub fn blocking_pick_folders(self) -> Option> { + blocking_fn!(self, pick_folders) + } + + /// Shows the dialog to save a file. + /// + /// This is a blocking operation, + /// and should *NOT* be used when running on the main thread. + /// + /// See [`Self::save_file`] for a non-blocking version for use in main-thread contexts. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_dialog::DialogExt; + /// #[tauri::command] + /// async fn my_command(app: tauri::AppHandle) { + /// let file_path = app.dialog().file().blocking_save_file(); + /// // do something with the optional file path here + /// // the file path is `None` if the user closed the dialog + /// } + /// ``` + pub fn blocking_save_file(self) -> Option { + blocking_fn!(self, save_file) + } +} diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs new file mode 100644 index 0000000..46ea3a2 --- /dev/null +++ b/plugins/dialog/src/mobile.rs @@ -0,0 +1,127 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Deserialize}; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::{FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogResult}; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.dialog"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_dialog); + +// initializes the Kotlin or Swift plugin classes +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "DialogPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_dialog)?; + Ok(Dialog(handle)) +} + +/// Access to the dialog APIs. +#[derive(Debug)] +pub struct Dialog(PluginHandle); + +impl Clone for Dialog { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Dialog { + pub(crate) fn app_handle(&self) -> &AppHandle { + self.0.app() + } +} + +#[derive(Debug, Deserialize)] +struct FilePickerResponse { + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct SaveFileResponse { + file: FilePath, +} + +pub fn pick_file) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFilePicker", dialog.payload(false)); + if let Ok(response) = res { + f(Some(response.files.into_iter().next().unwrap())) + } else { + f(None) + } + }); +} + +pub fn pick_files>) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFilePicker", dialog.payload(true)); + if let Ok(response) = res { + f(Some(response.files)) + } else { + f(None) + } + }); +} + +pub fn save_file) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("saveFileDialog", dialog.payload(false)); + if let Ok(response) = res { + f(Some(response.file)) + } else { + f(None) + } + }); +} + +#[derive(Debug, Deserialize)] +struct ShowMessageDialogResponse { + value: String, +} + +/// Shows a message dialog +pub fn show_message_dialog( + dialog: MessageDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showMessageDialog", dialog.payload()); + + let res = res.map(|res| res.value.into()); + f(res.unwrap_or_default()) + }); +} diff --git a/plugins/dialog/src/models.rs b/plugins/dialog/src/models.rs new file mode 100644 index 0000000..2c4f6ec --- /dev/null +++ b/plugins/dialog/src/models.rs @@ -0,0 +1,109 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Types of message, ask and confirm dialogs. +#[non_exhaustive] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] +pub enum MessageDialogKind { + /// Information dialog. + #[default] + Info, + /// Warning dialog. + Warning, + /// Error dialog. + Error, +} + +impl<'de> Deserialize<'de> for MessageDialogKind { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(match s.to_lowercase().as_str() { + "info" => MessageDialogKind::Info, + "warning" => MessageDialogKind::Warning, + "error" => MessageDialogKind::Error, + _ => MessageDialogKind::Info, + }) + } +} + +impl Serialize for MessageDialogKind { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match self { + Self::Info => serializer.serialize_str("info"), + Self::Warning => serializer.serialize_str("warning"), + Self::Error => serializer.serialize_str("error"), + } + } +} + +/// Set of button that will be displayed on the dialog +#[non_exhaustive] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub enum MessageDialogButtons { + #[default] + /// A single `Ok` button with OS default dialog text + Ok, + /// 2 buttons `Ok` and `Cancel` with OS default dialog texts + OkCancel, + /// 2 buttons `Yes` and `No` with OS default dialog texts + YesNo, + /// 3 buttons `Yes`, `No` and `Cancel` with OS default dialog texts + YesNoCancel, + /// A single `Ok` button with custom text + OkCustom(String), + /// 2 buttons `Ok` and `Cancel` with custom texts + OkCancelCustom(String, String), + /// 3 buttons `Yes`, `No` and `Cancel` with custom texts + YesNoCancelCustom(String, String, String), +} + +/// Result of a message dialog +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub enum MessageDialogResult { + /// The user pressed the `Yes` button. + Yes, + /// The user pressed the `No` button. + No, + /// The user pressed the `Ok` button. + Ok, + /// The user pressed the `Cancel` button, or closed the dialog without pressing a button. + #[default] + Cancel, + /// The user pressed a button with a custom label, holding that label's text. + #[serde(untagged)] + Custom(String), +} + +#[cfg(desktop)] +impl From for MessageDialogResult { + fn from(result: rfd::MessageDialogResult) -> Self { + match result { + rfd::MessageDialogResult::Yes => Self::Yes, + rfd::MessageDialogResult::No => Self::No, + rfd::MessageDialogResult::Ok => Self::Ok, + rfd::MessageDialogResult::Cancel => Self::Cancel, + rfd::MessageDialogResult::Custom(s) => Self::Custom(s), + } + } +} + +impl From for MessageDialogResult { + fn from(value: String) -> Self { + match value.as_str() { + "Yes" => Self::Yes, + "No" => Self::No, + "Ok" => Self::Ok, + "Cancel" => Self::Cancel, + _ => Self::Custom(value), + } + } +} diff --git a/plugins/dialog/test/tauri.conf.json b/plugins/dialog/test/tauri.conf.json new file mode 100644 index 0000000..4d0ae02 --- /dev/null +++ b/plugins/dialog/test/tauri.conf.json @@ -0,0 +1,21 @@ +{ + "identifier": "app.tauri.example", + "build": { + "frontendDist": ".", + "devUrl": "http://localhost:4000" + }, + "app": { + "windows": [ + { + "title": "Tauri App" + } + ], + "security": { + "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: http://tauri.localhost 'unsafe-eval' 'unsafe-inline' 'self'" + } + }, + "bundle": { + "active": true, + "icon": ["../../../examples/api/src-tauri/icons/icon.png"] + } +} diff --git a/plugins/dialog/tsconfig.json b/plugins/dialog/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/dialog/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/fs/CHANGELOG.md b/plugins/fs/CHANGELOG.md new file mode 100644 index 0000000..2622d13 --- /dev/null +++ b/plugins/fs/CHANGELOG.md @@ -0,0 +1,232 @@ +# Changelog + +## [2.5.2] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. +- [`685610ae`](https://github.com/tauri-apps/plugins-workspace/commit/685610ae782b668770d412f42a5d52d2cc9e497e) ([#3507](https://github.com/tauri-apps/plugins-workspace/pull/3507) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `fs:default` and `fs:read-app-specific-dirs-recursive` not giving any command scopes +- [`3fb27bf1`](https://github.com/tauri-apps/plugins-workspace/commit/3fb27bf13a88dd1e5aa2cc933408be02b0eafc91) ([#3504](https://github.com/tauri-apps/plugins-workspace/pull/3504) by [@vasfvitor](https://github.com/tauri-apps/plugins-workspace/../../vasfvitor)) Fixed `deny-webview-data` has no effect, and on Linux, only deny access to the webview data paths instead of the entire `$APPLOCALDATA` + +## \[2.5.1] + +- [`ec054013`](https://github.com/tauri-apps/plugins-workspace/commit/ec0540138bece081e9a87982091947360e61987a) ([#3323](https://github.com/tauri-apps/plugins-workspace/pull/3323) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated dependency `toml` from 0.9 to 1 + +## \[2.5.0] + +- [`e97a4ded`](https://github.com/tauri-apps/plugins-workspace/commit/e97a4dedab8608d9d57029784ee8a2607ca3a996) ([#3244](https://github.com/tauri-apps/plugins-workspace/pull/3244) by [@SeijiOkuda](https://github.com/tauri-apps/plugins-workspace/../../SeijiOkuda)) Add `encoding` option for `readTextFile` and `readTextFileLines` +- [`f75d21db`](https://github.com/tauri-apps/plugins-workspace/commit/f75d21db3351d6f12adf585c2c797c20ece94f7f) ([#3360](https://github.com/tauri-apps/plugins-workspace/pull/3360) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Removed the dependency on `tauri-utils`'s `build` feature +- [`f5f68063`](https://github.com/tauri-apps/plugins-workspace/commit/f5f68063e459c33522f44a04df3120d9d039ec13) ([#3185](https://github.com/tauri-apps/plugins-workspace/pull/3185) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Enable access for security-scoped resources on iOS by automatically calling `NSURL::startAccessingSecurityScopedResource` on resource access and adding the `stopAccessingSecurityScopedResource` API. + +## \[2.4.5] + +- [`521cd8b3`](https://github.com/tauri-apps/plugins-workspace/commit/521cd8b372c862d96b1637775710e4d7cf2443e2) ([#3155](https://github.com/tauri-apps/plugins-workspace/pull/3155) by [@EliasStar](https://github.com/tauri-apps/plugins-workspace/../../EliasStar)) Fix off by one error in the implementation of readTextFileLines causing all lines to end with an (additional) null byte. + Issue: [#3154](https://github.com/tauri-apps/plugins-workspace/issues/3154) + PR: [#3155](https://github.com/tauri-apps/plugins-workspace/pull/3155) + +## \[2.4.4] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.4.3] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. +- [`6b5b1053`](https://github.com/tauri-apps/plugins-workspace/commit/6b5b1053ba8aeb789dd5cb5fb05b7e98f3b8de0b) ([#1939](https://github.com/tauri-apps/plugins-workspace/pull/1939) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Enhance error messages. + +## \[2.4.2] + +- [`4eb36b0f`](https://github.com/tauri-apps/plugins-workspace/commit/4eb36b0ff57acb0bb1b911c583efa3bf2f56aa32) ([#2907](https://github.com/tauri-apps/plugins-workspace/pull/2907) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fixed calling `writeFile` with `data: ReadableStream` throws `Invalid argument` +- [`515182a1`](https://github.com/tauri-apps/plugins-workspace/commit/515182a179d4439079b2b7f6927555ba5ab0b035) ([#2915](https://github.com/tauri-apps/plugins-workspace/pull/2915) by [@samhinshaw](https://github.com/tauri-apps/plugins-workspace/../../samhinshaw)) `readFile` now returns a more specific type `Promise>` instead of the default `Promise` + +## \[2.4.1] + +- [`44a1f659`](https://github.com/tauri-apps/plugins-workspace/commit/44a1f659125a341191420e650608b0b6ff316a0e) ([#2846](https://github.com/tauri-apps/plugins-workspace/pull/2846) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `writeFile` doesn't create a new file by default when the data is a `ReadableStream` + +## \[2.4.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.3.0] + +- [`dac4d537`](https://github.com/tauri-apps/plugins-workspace/commit/dac4d53724bb3430a00a3f0119857cba32a031e8) ([#2613](https://github.com/tauri-apps/plugins-workspace/pull/2613) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Reduce the overhead of `watch` and `unwatch` + +## \[2.2.1] + +### bug + +- [`831c35ff`](https://github.com/tauri-apps/plugins-workspace/commit/831c35ff3940e841fe4418bb4cb104038b03304b) ([#2550](https://github.com/tauri-apps/plugins-workspace/pull/2550)) Fix `writeFile` ReadableStream handling due to missing async iterator support on macOS platform + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.4] + +- [`77b85507`](https://github.com/tauri-apps/plugins-workspace/commit/77b855074aad612f2b28e6a3b5881fac767a05ae) ([#2171](https://github.com/tauri-apps/plugins-workspace/pull/2171) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed docs.rs build. + +## \[2.0.3] + +- [`ed981027`](https://github.com/tauri-apps/plugins-workspace/commit/ed981027dd4fba7d0e2f836eb5db34d344388d73) ([#1962](https://github.com/tauri-apps/plugins-workspace/pull/1962) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Improve performance of `readTextFile` and `readTextFileLines` APIs +- [`3e78173d`](https://github.com/tauri-apps/plugins-workspace/commit/3e78173df9ce90aa3b19e1f36d1f8712c5020fb6) ([#2018](https://github.com/tauri-apps/plugins-workspace/pull/2018) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix `readDir` function failing to read directories that contain broken symlinks. +- [`5092ea5e`](https://github.com/tauri-apps/plugins-workspace/commit/5092ea5e89817c0550d09b0a4ad17bf1253b23df) ([#1964](https://github.com/tauri-apps/plugins-workspace/pull/1964) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add support for using `ReadableStream` with `writeFile` API. + +## \[2.0.2] + +- [`77149dc4`](https://github.com/tauri-apps/plugins-workspace/commit/77149dc4320d26b413e4a6bbe82c654367c51b32) ([#1965](https://github.com/tauri-apps/plugins-workspace/pull/1965) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix `writeTextFile` converting UTF-8 characters (for example `äöü`) in the given path into replacement character (`�`) + +## \[2.0.3] + +- [`14cee64c`](https://github.com/tauri-apps/plugins-workspace/commit/14cee64c82a72655ae6a4ac0892736a2959dbda5) ([#1958](https://github.com/tauri-apps/plugins-workspace/pull/1958) by [@bWanShiTong](https://github.com/tauri-apps/plugins-workspace/../../bWanShiTong)) Fix compilation on targets with pointer width of `16` or `32` + +## \[2.0.1] + +- [`ae802456`](https://github.com/tauri-apps/plugins-workspace/commit/ae8024565f074f313084777c8b10d1b5e3bbe220) ([#1950](https://github.com/tauri-apps/plugins-workspace/pull/1950) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Improve performance of the `FileHandle.read` and `writeTextFile` APIs. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.6] + +- [`fc9b189e`](https://github.com/tauri-apps/plugins-workspace/commit/fc9b189e83a29bd750714ec6336133c6eabdfa20) ([#1837](https://github.com/tauri-apps/plugins-workspace/pull/1837) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fix failing to deserialize capability file when using an OS specific path in the scope that is not available on the current OS. + +## \[2.0.0-rc.5] + +- [`cc03ccf5`](https://github.com/tauri-apps/plugins-workspace/commit/cc03ccf5e0e4be8bbf50bbdebe957c84be7f779b) ([#1774](https://github.com/tauri-apps/plugins-workspace/pull/1774)) Fix `scope-app`, `scope-app-recursive` and `scope-index` not properly enabling the application paths. + +## \[2.0.0-rc.4] + +- [`9291e4d2`](https://github.com/tauri-apps/plugins-workspace/commit/9291e4d2caa31c883c71e55f2193bd8754d72f03) ([#1640](https://github.com/tauri-apps/plugins-workspace/pull/1640) by [@SRutile](https://github.com/tauri-apps/plugins-workspace/../../SRutile)) Support any UTF-8 character in the writeFile API. + +## \[2.0.0-rc.3] + +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add utility methods on `FilePath` and `SafeFilePath` enums which are: + + - `path` + - `simplified` + - `into_path` +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Implement `Serialize`, `Deserialize`, `From`, `TryFrom` and `FromStr` traits for `FilePath` and `SafeFilePath` enums. +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Mark `Error` enum as `#[non_exhuastive]`. +- [`a2fe5551`](https://github.com/tauri-apps/plugins-workspace/commit/a2fe55512f908dd11c814ce021d164f01677572a) ([#1727](https://github.com/tauri-apps/plugins-workspace/pull/1727) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `SafeFilePath` enum. + +## \[2.0.0-rc.2] + +- [`f7280c88`](https://github.com/tauri-apps/plugins-workspace/commit/f7280c88309cdf1f2330574fec31e26e01e9cdbd) ([#1710](https://github.com/tauri-apps/plugins-workspace/pull/1710) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix can't use Windows paths like `C:/Users/UserName/file.txt` + +### bug + +- [`51819c60`](https://github.com/tauri-apps/plugins-workspace/commit/51819c601f863cbfbd11809a1c4cf9df5a20b1e0) ([#1708](https://github.com/tauri-apps/plugins-workspace/pull/1708) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fixes `writeFile` command implementation on Android. + +## \[2.0.0-rc.2] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`5f689902`](https://github.com/tauri-apps/plugins-workspace/commit/5f68990297f2cefac4220649a469adb7fa94fe1b) ([#1645](https://github.com/tauri-apps/plugins-workspace/pull/1645) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update documentation. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.6] + +- [`b115fd22`](https://github.com/tauri-apps/plugins-workspace/commit/b115fd22e0da073f5d758c13474ec2106cf78163)([#1221](https://github.com/tauri-apps/plugins-workspace/pull/1221)) Fixes an issue that caused the app to freeze when the `dialog`, `fs`, and `persisted-scope` plugins were used together. + +## \[2.0.0-beta.5] + +- [`bb51a41`](https://github.com/tauri-apps/plugins-workspace/commit/bb51a41d67ebf989e8aedf10c4b1a7f9514d1bdf)([#1168](https://github.com/tauri-apps/plugins-workspace/pull/1168)) **Breaking Change:** All apis that return paths to the frontend will now remove the `\\?\` UNC prefix on Windows. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Internally use the webview scoped resources table instead of the app one, so other webviews can't access other webviews resources. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Update for tauri 2.0.0-beta.15. + +## \[2.0.0-beta.4] + +- [`9c2fb93`](https://github.com/tauri-apps/plugins-workspace/commit/9c2fb9306ecd3936a2aef56b3c012899036db098) Enhance the scope type to also allow a plain string representing the path to allow or deny. +- [`772f2bc`](https://github.com/tauri-apps/plugins-workspace/commit/772f2bc3495a4f83f1c3e538cbac6d29cbd7d5ef)([#1136](https://github.com/tauri-apps/plugins-workspace/pull/1136)) Update for tauri 2.0.0-beta.14. + +## \[2.0.0-beta.3] + +- [`cb96aa0`](https://github.com/tauri-apps/plugins-workspace/commit/cb96aa06277f7b864952827ec9fb1e74c8a1f761)([#1082](https://github.com/tauri-apps/plugins-workspace/pull/1082)) Fixes `watch` and `watchImmediate` which previously ignored the `baseDir` parameter. +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`7358102`](https://github.com/tauri-apps/plugins-workspace/commit/735810237e21504a027a65a7b3c25fd7e594288a)([#1029](https://github.com/tauri-apps/plugins-workspace/pull/1029)) Fix infinite loop on cargo build operations +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`ea8eadc`](https://github.com/tauri-apps/plugins-workspace/commit/ea8eadce85b2e3e8eb7eb1a779fc3aa6c1201fa3)([#865](https://github.com/tauri-apps/plugins-workspace/pull/865)) Fix incorrect `create` option default value for `writeFile` and `writeTextFile` which didn't match documentation. +- [`61edbbe`](https://github.com/tauri-apps/plugins-workspace/commit/61edbbec0acda4213ed8684f75a973e8be123a52)([#885](https://github.com/tauri-apps/plugins-workspace/pull/885)) Replace `notify-debouncer-mini` with `notify-debouncer-full`. [(plugins-workspace#885)](https://github.com/tauri-apps/plugins-workspace/pull/885) + +## \[2.0.0-alpha.6] + +- [`85f8419`](https://github.com/tauri-apps/plugins-workspace/commit/85f841968200316958d707db0c39bb115f762471)([#848](https://github.com/tauri-apps/plugins-workspace/pull/848)) Fix `DebouncedEvent` type to correctly represent the actual type. +- [`c601230`](https://github.com/tauri-apps/plugins-workspace/commit/c60123093ddf725af7228494182fed697ff8b021)([#847](https://github.com/tauri-apps/plugins-workspace/pull/847)) Add `createNew` option for `writeFile` and `writeTextFile` to create the file if doesn't exist and fail if it does. +- [`c601230`](https://github.com/tauri-apps/plugins-workspace/commit/c60123093ddf725af7228494182fed697ff8b021)([#847](https://github.com/tauri-apps/plugins-workspace/pull/847)) Truncate files when using `writeFile` and `writeTextFile` with `append: false`. +- [`2e2fc8d`](https://github.com/tauri-apps/plugins-workspace/commit/2e2fc8de69dd8d282b66ec81561d57d8af802dc5)([#857](https://github.com/tauri-apps/plugins-workspace/pull/857)) Fix `invalid args id for command unwatch` error when trying to unwatch a previously watched file or directory. +- [`c601230`](https://github.com/tauri-apps/plugins-workspace/commit/c60123093ddf725af7228494182fed697ff8b021)([#847](https://github.com/tauri-apps/plugins-workspace/pull/847)) Fix panic when using `writeFile` or `writeTextFile` without passing an option object. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. +- [`69a1fa0`](https://github.com/tauri-apps/plugins-workspace/commit/69a1fa099c3143b6e426492f1c9d9cfbe56d2209)([#751](https://github.com/tauri-apps/plugins-workspace/pull/751)) The `fs` plugin received a major overhaul to add new APIs and changed existing APIs to be closer to Node.js and Deno APIs. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. +- [`88d260d`](https://github.com/tauri-apps/plugins-workspace/commit/88d260d90130f9df4b9ce00c1ad1bf1e4b30b1c0)([#744](https://github.com/tauri-apps/plugins-workspace/pull/744)) Add second argument to `exists` function to specify base directory. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`0bba693`](https://github.com/tauri-apps/plugins-workspace/commit/0bba6932c09da5267a9dbf75ba52252e39458420)([#454](https://github.com/tauri-apps/plugins-workspace/pull/454)) Fix `writeBinaryFile` crashing with `command 'write_binary_file' not found` +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/fs/Cargo.toml b/plugins/fs/Cargo.toml new file mode 100644 index 0000000..74f7f6a --- /dev/null +++ b/plugins/fs/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "tauri-plugin-fs" +version = "2.5.2" +description = "Access the file system." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-fs" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "Apps installed via MSI or NSIS in `perMachine` and `both` mode require admin permissions for write access in `$RESOURCES` folder" } +linux = { level = "full", notes = "No write access to `$RESOURCES` folder" } +macos = { level = "full", notes = "No write access to `$RESOURCES` folder" } +android = { level = "partial", notes = "Access is restricted to Application folder by default" } +ios = { level = "partial", notes = "Access is restricted to Application folder by default" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } +schemars = { workspace = true } +serde = { workspace = true } +toml = "1.0" +tauri-utils = { workspace = true } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_repr = "0.1" +tauri = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +log = { workspace = true } +anyhow = "1" +glob = { workspace = true } +# TODO: Remove `serialization-compat-6` in v3 +notify = { version = "8", optional = true, features = [ + "serde", + "serialization-compat-6", +] } +notify-debouncer-full = { version = "0.6", optional = true } +dunce = { workspace = true } +percent-encoding = "2" + +[target.'cfg(target_os = "ios")'.dependencies] +objc2-foundation = { version = "0.3", features = ["NSURL", "NSString"] } + +[features] +watch = ["notify", "notify-debouncer-full"] diff --git a/plugins/fs/LICENSE.spdx b/plugins/fs/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/fs/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/fs/LICENSE_APACHE-2.0 b/plugins/fs/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/fs/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/fs/LICENSE_MIT b/plugins/fs/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/fs/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/fs/README.md b/plugins/fs/README.md new file mode 100644 index 0000000..d02c71d --- /dev/null +++ b/plugins/fs/README.md @@ -0,0 +1,91 @@ +![plugin-fs](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/fs/banner.png) + +Access the file system. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-fs = "2.0.0" +# alternatively with Git: +tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-fs +# or +npm add @tauri-apps/plugin-fs +# or +yarn add @tauri-apps/plugin-fs +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { stat } from '@tauri-apps/plugin-fs' + +await stat('/path/to/file') +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/fs/SECURITY.md b/plugins/fs/SECURITY.md new file mode 100644 index 0000000..838ed67 --- /dev/null +++ b/plugins/fs/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). + +## Threat Model + +This plugin possibly allows access to the full filesystem available to the application process. +Depending on the operating system the access is already confined (android/ios) to only certain locations. +In other operating systems like Linux/MacOS/Windows it depends on the installation and packaging method but in most cases full +access is granted. + +To prevent exposure of sensitive locations and data this plugin can be scoped to only allow certain base directories +or only access to specific files or subdirectories. +This scoping effectively affects only calls made from the webviews/frontend code and calls made from rust can always circumvent +the restrictions imposed by the scope. + +The scope is defined at compile time in the used permissions but the user or application developer can grant or revoke access to specific files or folders at runtime by modifying the scope state through the runtime authority, if configured during plugin initialization. + +### Security Assumptions + +- The filesystem access is limited by user permissions +- The operating system filesystem access confinment works as documented +- The scoping mechanism of the Tauri `fs` commands work as intended and has no bypasses +- The user or application developer can grant or revoke access to specific files at runtime by modifying the scope + +#### Out Of Scope + +- Exploits in underlying filesystems +- Exploits in the underlying rust `std::fs` library diff --git a/plugins/fs/android/.gitignore b/plugins/fs/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/fs/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/fs/android/build.gradle.kts b/plugins/fs/android/build.gradle.kts new file mode 100644 index 0000000..b1b6af1 --- /dev/null +++ b/plugins/fs/android/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.plugin.fs" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/fs/android/consumer-rules.pro b/plugins/fs/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/fs/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/fs/android/settings.gradle b/plugins/fs/android/settings.gradle new file mode 100644 index 0000000..d7782a4 --- /dev/null +++ b/plugins/fs/android/settings.gradle @@ -0,0 +1,31 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + resolutionStrategy { + eachPlugin { + switch (requested.id.id) { + case "com.android.library": + useVersion("8.0.2") + break + case "org.jetbrains.kotlin.android": + useVersion("1.8.20") + break + } + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + + } +} + +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c3b473f --- /dev/null +++ b/plugins/fs/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.plugin.fs + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.plugin.fs", appContext.packageName) + } +} diff --git a/plugins/fs/android/src/main/AndroidManifest.xml b/plugins/fs/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/plugins/fs/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/plugins/fs/android/src/main/java/FsPlugin.kt b/plugins/fs/android/src/main/java/FsPlugin.kt new file mode 100644 index 0000000..877fbf4 --- /dev/null +++ b/plugins/fs/android/src/main/java/FsPlugin.kt @@ -0,0 +1,93 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.plugin.fs + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.res.AssetManager.ACCESS_BUFFER +import android.net.Uri +import android.os.ParcelFileDescriptor +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +@InvokeArg +class WriteTextFileArgs { + val uri: String = "" + val content: String = "" +} + +@InvokeArg +class GetFileDescriptorArgs { + lateinit var uri: String + lateinit var mode: String +} + +@TauriPlugin +class FsPlugin(private val activity: Activity): Plugin(activity) { + @SuppressLint("Recycle") + @Command + fun getFileDescriptor(invoke: Invoke) { + val args = invoke.parseArgs(GetFileDescriptorArgs::class.java) + + val res = JSObject() + + if (args.uri.startsWith(app.tauri.TAURI_ASSETS_DIRECTORY_URI)) { + val path = args.uri.substring(app.tauri.TAURI_ASSETS_DIRECTORY_URI.length) + try { + val fd = activity.assets.openFd(path).parcelFileDescriptor?.detachFd() + res.put("fd", fd) + } catch (e: IOException) { + // if the asset is compressed, we cannot open a file descriptor directly + // so we copy it to the cache and get a fd from there + // this is a lot faster than serializing the file and sending it as invoke response + // because on the Rust side we can leverage the custom protocol IPC and read the file directly + val cacheFile = File(activity.cacheDir, "_assets/$path") + cacheFile.parentFile?.mkdirs() + copyAsset(path, cacheFile) + + val fd = ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.parseMode(args.mode)).detachFd() + res.put("fd", fd) + } + } else { + val fd = activity.contentResolver.openAssetFileDescriptor( + Uri.parse(args.uri), + args.mode + )?.parcelFileDescriptor?.detachFd() + res.put("fd", fd) + } + + invoke.resolve(res) + } + + @Throws(IOException::class) + private fun copy(input: InputStream, output: OutputStream) { + val buf = ByteArray(1024) + var len: Int + while ((input.read(buf).also { len = it }) > 0) { + output.write(buf, 0, len) + } + } + + @Throws(IOException::class) + private fun copyAsset(assetPath: String, cacheFile: File) { + val input = activity.assets.open(assetPath, ACCESS_BUFFER) + input.use { i -> + val output = FileOutputStream(cacheFile, false) + output.use { o -> + copy(i, o) + } + } + } +} + diff --git a/plugins/fs/android/src/test/java/ExampleUnitTest.kt b/plugins/fs/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..340839a --- /dev/null +++ b/plugins/fs/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.plugin.fs + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/fs/api-iife.js b/plugins/fs/api-iife.js new file mode 100644 index 0000000..ba06038 --- /dev/null +++ b/plugins/fs/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(e,n,t){"use strict";var i;function o(e){return{isFile:e.isFile,isDirectory:e.isDirectory,isSymlink:e.isSymlink,size:e.size,mtime:null!==e.mtime?new Date(e.mtime):null,atime:null!==e.atime?new Date(e.atime):null,birthtime:null!==e.birthtime?new Date(e.birthtime):null,readonly:e.readonly,fileAttributes:e.fileAttributes,dev:e.dev,ino:e.ino,mode:e.mode,nlink:e.nlink,uid:e.uid,gid:e.gid,rdev:e.rdev,blksize:e.blksize,blocks:e.blocks}}e.SeekMode=void 0,(i=e.SeekMode||(e.SeekMode={}))[i.Start=0]="Start",i[i.Current=1]="Current",i[i.End=2]="End";class r extends t.Resource{async read(e){if(0===e.byteLength)return 0;const n=await t.invoke("plugin:fs|read",{rid:this.rid,len:e.byteLength}),i=function(e){const n=new Uint8ClampedArray(e),t=n.byteLength;let i=0;for(let e=0;ee instanceof URL?e.toString():e),options:i,onEvent:r}),c=new s(a);return()=>{c.close()}}return Object.defineProperty(e,"BaseDirectory",{enumerable:!0,get:function(){return n.BaseDirectory}}),e.FileHandle=r,e.copyFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol||n instanceof URL&&"file:"!==n.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|copy_file",{fromPath:e instanceof URL?e.toString():e,toPath:n instanceof URL?n.toString():n,options:i})},e.create=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|create",{path:e instanceof URL?e.toString():e,options:n});return new r(i)},e.exists=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|exists",{path:e instanceof URL?e.toString():e,options:n})},e.lstat=async function(e,n){return o(await t.invoke("plugin:fs|lstat",{path:e instanceof URL?e.toString():e,options:n}))},e.mkdir=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|mkdir",{path:e instanceof URL?e.toString():e,options:n})},e.open=a,e.readDir=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|read_dir",{path:e instanceof URL?e.toString():e,options:n})},e.readFile=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|read_file",{path:e instanceof URL?e.toString():e,options:n});return i instanceof ArrayBuffer?new Uint8Array(i):Uint8Array.from(i)},e.readTextFile=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|read_text_file",{path:e instanceof URL?e.toString():e,options:n}),o=i instanceof ArrayBuffer?i:Uint8Array.from(i);return new TextDecoder(n?.encoding??"utf-8").decode(o)},e.readTextFileLines=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=e instanceof URL?e.toString():e;return await Promise.resolve({path:i,rid:null,async next(){const e=new TextDecoder(n?.encoding??"utf-8");if(null===this.rid){const o=e.encoding;this.rid=await t.invoke("plugin:fs|read_text_file_lines",{path:i,options:null!=n?{...n,encoding:o}:void 0})}const o=await t.invoke("plugin:fs|read_text_file_lines_next",{rid:this.rid}),r=o instanceof ArrayBuffer?new Uint8Array(o):Uint8Array.from(o),a=1===r[r.byteLength-1];if(a)return this.rid=null,{value:null,done:a};return{value:e.decode(r.slice(0,r.byteLength-1)),done:a}},[Symbol.asyncIterator](){return this}})},e.remove=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|remove",{path:e instanceof URL?e.toString():e,options:n})},e.rename=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol||n instanceof URL&&"file:"!==n.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|rename",{oldPath:e instanceof URL?e.toString():e,newPath:n instanceof URL?n.toString():n,options:i})},e.size=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|size",{path:e instanceof URL?e.toString():e})},e.startAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|start_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.stat=async function(e,n){return o(await t.invoke("plugin:fs|stat",{path:e instanceof URL?e.toString():e,options:n}))},e.stopAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|stop_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.truncate=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|truncate",{path:e instanceof URL?e.toString():e,len:n,options:i})},e.watch=async function(e,n,t){return await c(e,n,{delayMs:2e3,...t})},e.watchImmediate=async function(e,n,t){return await c(e,n,{...t,delayMs:void 0})},e.writeFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");if(n instanceof ReadableStream){const t=await a(e,{read:!1,create:!0,write:!0,...i}),o=n.getReader();try{for(;;){const{done:e,value:n}=await o.read();if(e)break;await t.write(n)}}finally{o.releaseLock(),await t.close()}}else await t.invoke("plugin:fs|write_file",n,{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e.writeTextFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const o=new TextEncoder;await t.invoke("plugin:fs|write_text_file",o.encode(n),{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e}({},window.__TAURI__.path,window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} diff --git a/plugins/fs/banner.png b/plugins/fs/banner.png new file mode 100644 index 0000000..767a1fe Binary files /dev/null and b/plugins/fs/banner.png differ diff --git a/plugins/fs/build.rs b/plugins/fs/build.rs new file mode 100644 index 0000000..34b9475 --- /dev/null +++ b/plugins/fs/build.rs @@ -0,0 +1,269 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + fs::create_dir_all, + path::{Path, PathBuf}, +}; + +use tauri_utils::acl::manifest::PermissionFile; + +#[path = "src/scope.rs"] +#[allow(dead_code)] +mod scope; + +/// FS scope entry. +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +#[allow(unused)] +enum FsScopeEntry { + /// A path that can be accessed by the webview when using the fs APIs. + /// FS scope path pattern. + /// + /// The pattern can start with a variable that resolves to a system base directory. + /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, + /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, + /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, + /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`. + Value(PathBuf), + Object { + /// A path that can be accessed by the webview when using the fs APIs. + /// + /// The pattern can start with a variable that resolves to a system base directory. + /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, + /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, + /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, + /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`. + path: PathBuf, + }, +} + +// Ensure `FsScopeEntry` and `scope::EntryRaw` is kept in sync +fn _f() { + match scope::EntryRaw::Value(PathBuf::new()) { + scope::EntryRaw::Value(path) => FsScopeEntry::Value(path), + scope::EntryRaw::Object { path } => FsScopeEntry::Object { path }, + }; + match FsScopeEntry::Value(PathBuf::new()) { + FsScopeEntry::Value(path) => scope::EntryRaw::Value(path), + FsScopeEntry::Object { path } => scope::EntryRaw::Object { path }, + }; +} + +const BASE_DIR_VARS: &[&str] = &[ + "AUDIO", + "CACHE", + "CONFIG", + "DATA", + "LOCALDATA", + "DESKTOP", + "DOCUMENT", + "DOWNLOAD", + "EXE", + "FONT", + "HOME", + "PICTURE", + "PUBLIC", + "RUNTIME", + "TEMPLATE", + "VIDEO", + "RESOURCE", + "LOG", + "TEMP", + "APPCONFIG", + "APPDATA", + "APPLOCALDATA", + "APPCACHE", + "APPLOG", +]; +const COMMANDS: &[(&str, &[&str])] = &[ + ("mkdir", &[]), + ("create", &[]), + ("copy_file", &[]), + ("remove", &[]), + ("rename", &[]), + ("truncate", &[]), + ("ftruncate", &[]), + ("write", &[]), + ("write_file", &["open", "write"]), + ("write_text_file", &[]), + ("read_dir", &[]), + ("read_file", &[]), + ("read", &[]), + ("open", &[]), + ("read_text_file", &[]), + ("read_text_file_lines", &["read_text_file_lines_next"]), + ("read_text_file_lines_next", &[]), + ("seek", &[]), + ("stat", &[]), + ("lstat", &[]), + ("fstat", &[]), + ("exists", &[]), + ("watch", &[]), + // TODO: Remove this in v3 + ("unwatch", &[]), + ("size", &[]), + ("start_accessing_security_scoped_resource", &[]), + ("stop_accessing_security_scoped_resource", &[]), +]; + +fn main() { + let autogenerated = Path::new("permissions/autogenerated/"); + let base_dirs = &autogenerated.join("base-directories"); + + if !base_dirs.exists() { + create_dir_all(base_dirs).expect("unable to create autogenerated base directories dir"); + } + + for base_dir in BASE_DIR_VARS { + let upper = base_dir; + let lower = base_dir.to_lowercase(); + let toml = format!( + r###"# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-{lower}-recursive" +description = "This scope permits recursive access to the complete `${upper}` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "${upper}" +[[permission.scope.allow]] +path = "${upper}/**" + +[[permission]] +identifier = "scope-{lower}" +description = "This scope permits access to all files and list content of top level directories in the `${upper}` folder." + +[[permission.scope.allow]] +path = "${upper}" +[[permission.scope.allow]] +path = "${upper}/*" + +[[permission]] +identifier = "scope-{lower}-index" +description = "This scope permits to list all files and folders in the `${upper}`folder." + +[[permission.scope.allow]] +path = "${upper}" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-{lower}-read-recursive" +description = "This allows full recursive read access to the complete `${upper}` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-{lower}-recursive" +] + +[[set]] +identifier = "allow-{lower}-write-recursive" +description = "This allows full recursive write access to the complete `${upper}` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-{lower}-recursive" +] + +[[set]] +identifier = "allow-{lower}-read" +description = "This allows non-recursive read access to the `${upper}` folder." +permissions = [ + "read-all", + "scope-{lower}" +] + +[[set]] +identifier = "allow-{lower}-write" +description = "This allows non-recursive write access to the `${upper}` folder." +permissions = [ + "write-all", + "scope-{lower}" +] + +[[set]] +identifier = "allow-{lower}-meta-recursive" +description = "This allows full recursive read access to metadata of the `${upper}` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-{lower}-recursive" +] + +[[set]] +identifier = "allow-{lower}-meta" +description = "This allows non-recursive read access to metadata of the `${upper}` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-{lower}-index" +]"### + ); + + let permission_path = base_dirs.join(format!("{lower}.toml")); + if toml != std::fs::read_to_string(&permission_path).unwrap_or_default() { + std::fs::write(permission_path, toml) + .unwrap_or_else(|e| panic!("unable to autogenerate ${lower}: {e}")); + } + } + + tauri_plugin::Builder::new( + &COMMANDS + .iter() + // FIXME: https://docs.rs/crate/tauri-plugin-fs/2.1.0/builds/1571296 + .filter(|c| c.1.is_empty()) + .map(|c| c.0) + .collect::>(), + ) + .global_api_script_path("./api-iife.js") + .global_scope_schema(schemars::schema_for!(FsScopeEntry)) + .android_path("android") + .build(); + + // workaround to include nested permissions as `tauri_plugin` doesn't support it + let permissions_dir = autogenerated.join("commands"); + for (command, nested_commands) in COMMANDS { + if nested_commands.is_empty() { + continue; + } + + let permission_path = permissions_dir.join(format!("{command}.toml")); + + let content = std::fs::read_to_string(&permission_path) + .unwrap_or_else(|_| panic!("failed to read {command}.toml")); + + let mut permission_file = toml::from_str::(&content) + .unwrap_or_else(|_| panic!("failed to deserialize {command}.toml")); + + for p in permission_file + .permission + .iter_mut() + .filter(|p| p.identifier.starts_with("allow")) + { + for c in nested_commands.iter().map(|s| s.to_string()) { + if !p.commands.allow.contains(&c) { + p.commands.allow.push(c); + } + } + } + + let out = toml::to_string_pretty(&permission_file) + .unwrap_or_else(|_| panic!("failed to serialize {command}.toml")); + let out = format!( + r#"# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +{out}"# + ); + + if content != out { + std::fs::write(permission_path, out) + .unwrap_or_else(|_| panic!("failed to write {command}.toml")); + } + } +} diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts new file mode 100644 index 0000000..2bea269 --- /dev/null +++ b/plugins/fs/guest-js/index.ts @@ -0,0 +1,1649 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Access the file system. + * + * ## iOS security-scoped resources + * + * On iOS, the `fs` plugin automatically manages access to security-scoped resources when a file URL is accessed. + * This is required for files outside the app's sandbox (e.g., from file picker). + * + * @example + * ```typescript + * import { open } from '@tauri-apps/plugin-fs'; + * + * const file = await open('file:///path/to/file.txt'); + * await file.close(); + * ``` + * + * ## Security + * + * This module prevents path traversal, not allowing parent directory accessors to be used + * (i.e. "/usr/path/to/../file" or "../path/to/file" paths are not allowed). + * Paths accessed with this API must be either relative to one of the {@link BaseDirectory | base directories} + * or created with the {@link https://v2.tauri.app/reference/javascript/api/namespacepath/ | path API}. + * + * The API has a scope configuration that forces you to restrict the paths that can be accessed using glob patterns. + * + * The scope configuration is an array of glob patterns describing file/directory paths that are allowed. + * For instance, this scope configuration allows **all** enabled `fs` APIs to (only) access files in the + * *databases* directory of the {@link https://v2.tauri.app/reference/javascript/api/namespacepath/#appdatadir | `$APPDATA` directory}: + * ```json + * { + * "permissions": [ + * { + * "identifier": "fs:scope", + * "allow": [{ "path": "$APPDATA/databases/*" }] + * } + * ] + * } + * ``` + * + * Scopes can also be applied to specific `fs` APIs by using the API's identifier instead of `fs:scope`: + * ```json + * { + * "permissions": [ + * { + * "identifier": "fs:allow-exists", + * "allow": [{ "path": "$APPDATA/databases/*" }] + * } + * ] + * } + * ``` + * + * Notice the use of the `$APPDATA` variable. The value is injected at runtime, resolving to the {@link https://v2.tauri.app/reference/javascript/api/namespacepath/#appdatadir | app data directory}. + * + * The available variables are: + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#appconfigdir | $APPCONFIG}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#appdatadir | $APPDATA}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#applocaldatadir | $APPLOCALDATA}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#appcachedir | $APPCACHE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#applogdir | $APPLOG}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#audiodir | $AUDIO}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#cachedir | $CACHE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#configdir | $CONFIG}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#datadir | $DATA}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#localdatadir | $LOCALDATA}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#desktopdir | $DESKTOP}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#documentdir | $DOCUMENT}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#downloaddir | $DOWNLOAD}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#executabledir | $EXE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#fontdir | $FONT}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#homedir | $HOME}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#picturedir | $PICTURE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#publicdir | $PUBLIC}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#runtimedir | $RUNTIME}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#templatedir | $TEMPLATE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#videodir | $VIDEO}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#resourcedir | $RESOURCE}, + * {@linkcode https://v2.tauri.app/reference/javascript/api/namespacepath/#tempdir | $TEMP}. + * + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ + +import { BaseDirectory } from '@tauri-apps/api/path' +import { Channel, invoke, Resource } from '@tauri-apps/api/core' + +/** + * Defines how the offset given to {@linkcode FileHandle.seek} is interpreted. + */ +enum SeekMode { + /** The offset is relative to the start of the file. */ + Start = 0, + /** The offset is relative to the current cursor position. */ + Current = 1, + /** The offset is relative to the end of the file. */ + End = 2 +} + +/** + * A FileInfo describes a file and is returned by `stat`, `lstat` or `fstat`. + * + * @since 2.0.0 + */ +interface FileInfo { + /** + * True if this is info for a regular file. Mutually exclusive to + * `FileInfo.isDirectory` and `FileInfo.isSymlink`. + */ + isFile: boolean + /** + * True if this is info for a regular directory. Mutually exclusive to + * `FileInfo.isFile` and `FileInfo.isSymlink`. + */ + isDirectory: boolean + /** + * True if this is info for a symlink. Mutually exclusive to + * `FileInfo.isFile` and `FileInfo.isDirectory`. + */ + isSymlink: boolean + /** + * The size of the file, in bytes. + */ + size: number + /** + * The last modification time of the file. This corresponds to the `mtime` + * field from `stat` on Linux/Mac OS and `ftLastWriteTime` on Windows. This + * may not be available on all platforms. + */ + mtime: Date | null + /** + * The last access time of the file. This corresponds to the `atime` + * field from `stat` on Unix and `ftLastAccessTime` on Windows. This may not + * be available on all platforms. + */ + atime: Date | null + /** + * The creation time of the file. This corresponds to the `birthtime` + * field from `stat` on Mac/BSD and `ftCreationTime` on Windows. This may + * not be available on all platforms. + */ + birthtime: Date | null + /** Whether this is a readonly (unwritable) file. */ + readonly: boolean + /** + * This field contains the file system attribute information for a file + * or directory. For possible values and their descriptions, see + * {@link https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants | File Attribute Constants} in the Windows Dev Center + * + * #### Platform-specific + * + * - **macOS / Linux / Android / iOS:** Unsupported. + */ + fileAttributes: number | null + /** + * ID of the device containing the file. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + dev: number | null + /** + * Inode number. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + ino: number | null + /** + * The underlying raw `st_mode` bits that contain the standard Unix + * permissions for this file/directory. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + mode: number | null + /** + * Number of hard links pointing to this file. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + nlink: number | null + /** + * User ID of the owner of this file. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + uid: number | null + /** + * Group ID of the owner of this file. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + gid: number | null + /** + * Device ID of this file. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + rdev: number | null + /** + * Blocksize for filesystem I/O. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + blksize: number | null + /** + * Number of blocks allocated to the file, in 512-byte units. + * + * #### Platform-specific + * + * - **Windows:** Unsupported. + */ + blocks: number | null +} + +interface UnparsedFileInfo { + isFile: boolean + isDirectory: boolean + isSymlink: boolean + size: number + mtime: number | null + atime: number | null + birthtime: number | null + readonly: boolean + fileAttributes: number + dev: number | null + ino: number | null + mode: number | null + nlink: number | null + uid: number | null + gid: number | null + rdev: number | null + blksize: number | null + blocks: number | null +} +function parseFileInfo(r: UnparsedFileInfo): FileInfo { + return { + isFile: r.isFile, + isDirectory: r.isDirectory, + isSymlink: r.isSymlink, + size: r.size, + mtime: r.mtime !== null ? new Date(r.mtime) : null, + atime: r.atime !== null ? new Date(r.atime) : null, + birthtime: r.birthtime !== null ? new Date(r.birthtime) : null, + readonly: r.readonly, + fileAttributes: r.fileAttributes, + dev: r.dev, + ino: r.ino, + mode: r.mode, + nlink: r.nlink, + uid: r.uid, + gid: r.gid, + rdev: r.rdev, + blksize: r.blksize, + blocks: r.blocks + } +} + +// https://mstn.github.io/2018/06/08/fixed-size-arrays-in-typescript/ +type FixedSizeArray = ReadonlyArray & { + length: N +} + +// https://gist.github.com/zapthedingbat/38ebfbedd98396624e5b5f2ff462611d +/** Converts a big-endian eight byte array to number */ +function fromBytes(buffer: FixedSizeArray): number { + const bytes = new Uint8ClampedArray(buffer) + const size = bytes.byteLength + let x = 0 + for (let i = 0; i < size; i++) { + // eslint-disable-next-line security/detect-object-injection + const byte = bytes[i] + x *= 0x100 + x += byte + } + return x +} + +/** + * The Tauri abstraction for reading and writing files. + * + * @since 2.0.0 + */ +class FileHandle extends Resource { + /** + * Reads up to `p.byteLength` bytes into `p`. It resolves to the number of + * bytes read (`0` < `n` <= `p.byteLength`) and rejects if any error + * encountered. Even if `read()` resolves to `n` < `p.byteLength`, it may + * use all of `p` as scratch space during the call. If some data is + * available but not `p.byteLength` bytes, `read()` conventionally resolves + * to what is available instead of waiting for more. + * + * When `read()` encounters end-of-file condition, it resolves to EOF + * (`null`). + * + * When `read()` encounters an error, it rejects with an error. + * + * Callers should always process the `n` > `0` bytes returned before + * considering the EOF (`null`). Doing so correctly handles I/O errors that + * happen after reading some bytes and also both of the allowed EOF + * behaviors. + * + * @example + * ```typescript + * import { open, BaseDirectory } from "@tauri-apps/plugin-fs" + * // if "$APPCONFIG/foo/bar.txt" contains the text "hello world": + * const file = await open("foo/bar.txt", { baseDir: BaseDirectory.AppConfig }); + * const buf = new Uint8Array(100); + * const numberOfBytesRead = await file.read(buf); // 11 bytes + * const text = new TextDecoder().decode(buf); // "hello world" + * await file.close(); + * ``` + * + * @param buffer The buffer the file contents are read into. + * @returns A promise resolving to the number of bytes read, or `null` when the end of the file was reached. + * @since 2.0.0 + */ + async read(buffer: Uint8Array): Promise { + if (buffer.byteLength === 0) { + return 0 + } + + const data = await invoke('plugin:fs|read', { + rid: this.rid, + len: buffer.byteLength + }) + + // Rust side will never return an empty array for this command and + // ensure there is at least 8 elements there. + // + // This is an optimization to include the number of read bytes (as bigendian bytes) + // at the end of returned array to avoid serialization overhead of separate values. + const nread = fromBytes(data.slice(-8) as FixedSizeArray) + + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data + buffer.set(bytes.slice(0, bytes.length - 8)) + + return nread === 0 ? null : nread + } + + /** + * Seek sets the offset for the next `read()` or `write()` to offset, + * interpreted according to `whence`: `Start` means relative to the + * start of the file, `Current` means relative to the current offset, + * and `End` means relative to the end. Seek resolves to the new offset + * relative to the start of the file. + * + * Seeking to an offset before the start of the file is an error. Seeking to + * any positive offset is legal, but the behavior of subsequent I/O + * operations on the underlying object is implementation-dependent. + * It returns the number of cursor position. + * + * @example + * ```typescript + * import { open, SeekMode, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * // Given hello.txt pointing to file with "Hello world", which is 11 bytes long: + * const file = await open('hello.txt', { read: true, write: true, truncate: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.write(new TextEncoder().encode("Hello world")); + * + * // Seek 6 bytes from the start of the file + * console.log(await file.seek(6, SeekMode.Start)); // "6" + * // Seek 2 more bytes from the current position + * console.log(await file.seek(2, SeekMode.Current)); // "8" + * // Seek backwards 2 bytes from the end of the file + * console.log(await file.seek(-2, SeekMode.End)); // "9" (e.g. 11-2) + * + * await file.close(); + * ``` + * + * @param offset The number of bytes the cursor is moved by. + * @param whence Defines the position the `offset` is relative to. + * @returns A promise resolving to the new cursor position, relative to the start of the file. + * @since 2.0.0 + */ + async seek(offset: number, whence: SeekMode): Promise { + return await invoke('plugin:fs|seek', { + rid: this.rid, + offset, + whence + }) + } + + /** + * Returns a {@linkcode FileInfo } for this file. + * + * @example + * ```typescript + * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const file = await open("file.txt", { read: true, baseDir: BaseDirectory.AppLocalData }); + * const fileInfo = await file.stat(); + * console.log(fileInfo.isFile); // true + * await file.close(); + * ``` + * + * @returns A promise resolving to the metadata of this file. + * @since 2.0.0 + */ + async stat(): Promise { + const res = await invoke('plugin:fs|fstat', { + rid: this.rid + }) + + return parseFileInfo(res) + } + + /** + * Truncates or extends this file, to reach the specified `len`. + * If `len` is not specified then the entire file contents are truncated. + * + * @example + * ```typescript + * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * // truncate the entire file + * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.truncate(); + * + * // truncate part of the file + * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.write(new TextEncoder().encode("Hello World")); + * await file.truncate(7); + * const data = new Uint8Array(32); + * await file.read(data); + * console.log(new TextDecoder().decode(data)); // Hello W + * await file.close(); + * ``` + * + * @param len The length the file is truncated or extended to, in bytes. When not provided the entire file contents are truncated. + * @since 2.0.0 + */ + async truncate(len?: number): Promise { + await invoke('plugin:fs|ftruncate', { + rid: this.rid, + len + }) + } + + /** + * Writes `data.byteLength` bytes from `data` to the underlying data stream. It + * resolves to the number of bytes written from `data` (`0` <= `n` <= + * `data.byteLength`) or reject with the error encountered that caused the + * write to stop early. `write()` must reject with a non-null error if + * would resolve to `n` < `data.byteLength`. `write()` must not modify the + * slice data, even temporarily. + * + * @example + * ```typescript + * import { open, write, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const encoder = new TextEncoder(); + * const data = encoder.encode("Hello world"); + * const file = await open("bar.txt", { write: true, baseDir: BaseDirectory.AppLocalData }); + * const bytesWritten = await file.write(data); // 11 + * await file.close(); + * ``` + * + * @param data The bytes written to the file. + * @returns A promise resolving to the number of bytes written. + * @since 2.0.0 + */ + async write(data: Uint8Array): Promise { + return await invoke('plugin:fs|write', { + rid: this.rid, + data + }) + } +} + +/** + * Options for the `create` function, which creates or truncates a file. + * + * @since 2.0.0 + */ +interface CreateOptions { + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Creates a file if none exists or truncates an existing file and resolves to + * an instance of {@linkcode FileHandle }. + * + * @example + * ```typescript + * import { create, BaseDirectory } from "@tauri-apps/plugin-fs" + * const file = await create("foo/bar.txt", { baseDir: BaseDirectory.AppConfig }); + * await file.write(new TextEncoder().encode("Hello world")); + * await file.close(); + * ``` + * + * @param path The path of the file, relative to `options.baseDir` when it is provided. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the handle of the created file. + * @since 2.0.0 + */ +async function create( + path: string | URL, + options?: CreateOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const rid = await invoke('plugin:fs|create', { + path: path instanceof URL ? path.toString() : path, + options + }) + + return new FileHandle(rid) +} + +/** + * Options for the `open` function, defining how the file is opened and which operations are allowed on it. + * + * @since 2.0.0 + */ +interface OpenOptions { + /** + * Sets the option for read access. This option, when `true`, means that the + * file should be read-able if opened. + */ + read?: boolean + /** + * Sets the option for write access. This option, when `true`, means that + * the file should be write-able if opened. If the file already exists, + * any write calls on it will overwrite its contents, by default without + * truncating it. + */ + write?: boolean + /** + * Sets the option for the append mode. This option, when `true`, means that + * writes will append to a file instead of overwriting previous contents. + * Note that setting `{ write: true, append: true }` has the same effect as + * setting only `{ append: true }`. + */ + append?: boolean + /** + * Sets the option for truncating a previous file. If a file is + * successfully opened with this option set it will truncate the file to `0` + * size if it already exists. The file must be opened with write access + * for truncate to work. + */ + truncate?: boolean + /** + * Sets the option to allow creating a new file, if one doesn't already + * exist at the specified path. Requires write or append access to be + * used. + */ + create?: boolean + /** + * Defaults to `false`. If set to `true`, no file, directory, or symlink is + * allowed to exist at the target location. Requires write or append + * access to be used. When createNew is set to `true`, create and truncate + * are ignored. + */ + createNew?: boolean + /** + * Permissions to use if creating the file (defaults to `0o666`, before + * the process's umask). + * Ignored on Windows. + */ + mode?: number + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Open a file and resolve to an instance of {@linkcode FileHandle}. The + * file does not need to previously exist if using the `create` or `createNew` + * open options. It is the callers responsibility to close the file when finished + * with it. + * + * @example + * ```typescript + * import { open, BaseDirectory } from "@tauri-apps/plugin-fs" + * const file = await open("foo/bar.txt", { read: true, write: true, baseDir: BaseDirectory.AppLocalData }); + * // Do work with file + * await file.close(); + * ``` + * + * @param path The path of the file, relative to `options.baseDir` when it is provided. + * @param options Options defining the base directory of `path` and how the file is opened. + * @returns A promise resolving to the handle of the open file. + * @since 2.0.0 + */ +async function open( + path: string | URL, + options?: OpenOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const rid = await invoke('plugin:fs|open', { + path: path instanceof URL ? path.toString() : path, + options + }) + + return new FileHandle(rid) +} + +/** + * Options for the `copyFile` function, defining the base directory of each path. + * + * @since 2.0.0 + */ +interface CopyFileOptions { + /** Base directory for `fromPath`. */ + fromPathBaseDir?: BaseDirectory + /** Base directory for `toPath`. */ + toPathBaseDir?: BaseDirectory +} + +/** + * Copies the contents and permissions of one file to another specified path, by default creating a new file if needed, else overwriting. + * @example + * ```typescript + * import { copyFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * await copyFile('app.conf', 'app.conf.bk', { fromPathBaseDir: BaseDirectory.AppConfig, toPathBaseDir: BaseDirectory.AppConfig }); + * ``` + * + * @param fromPath The path of the file to copy from. + * @param toPath The path of the file to copy to. + * @param options Options defining the base directory of each path. + * @since 2.0.0 + */ +async function copyFile( + fromPath: string | URL, + toPath: string | URL, + options?: CopyFileOptions +): Promise { + if ( + (fromPath instanceof URL && fromPath.protocol !== 'file:') + || (toPath instanceof URL && toPath.protocol !== 'file:') + ) { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|copy_file', { + fromPath: fromPath instanceof URL ? fromPath.toString() : fromPath, + toPath: toPath instanceof URL ? toPath.toString() : toPath, + options + }) +} + +/** + * Options for the `mkdir` function, which creates a directory. + * + * @since 2.0.0 + */ +interface MkdirOptions { + /** Permissions to use when creating the directory (defaults to `0o777`, before the process's umask). Ignored on Windows. */ + mode?: number + /** + * Defaults to `false`. If set to `true`, means that any intermediate directories will also be created (as with the shell command `mkdir -p`). + * */ + recursive?: boolean + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Creates a new directory with the specified path. + * @example + * ```typescript + * import { mkdir, BaseDirectory } from '@tauri-apps/plugin-fs'; + * await mkdir('users', { baseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param path The path of the directory to create. + * @param options Options defining the base directory of `path`, the directory permissions and whether intermediate directories are created. + * @since 2.0.0 + */ +async function mkdir( + path: string | URL, + options?: MkdirOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|mkdir', { + path: path instanceof URL ? path.toString() : path, + options + }) +} + +/** + * Options for the `readDir` function, which lists the entries of a directory. + * + * @since 2.0.0 + */ +interface ReadDirOptions { + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * A disk entry which is either a file, a directory or a symlink. + * + * This is the result of the {@linkcode readDir}. + * + * @since 2.0.0 + */ +interface DirEntry { + /** The name of the entry (file name with extension or directory name). */ + name: string + /** Specifies whether this entry is a directory or not. */ + isDirectory: boolean + /** Specifies whether this entry is a file or not. */ + isFile: boolean + /** Specifies whether this entry is a symlink or not. */ + isSymlink: boolean +} + +/** + * Reads the directory given by path and returns an array of `DirEntry`. + * @example + * ```typescript + * import { readDir, BaseDirectory } from '@tauri-apps/plugin-fs'; + * import { join } from '@tauri-apps/api/path'; + * const dir = 'users'; + * const entries = await readDir(dir, { baseDir: BaseDirectory.AppLocalData }); + * await processEntriesRecursively(dir, entries); + * async function processEntriesRecursively(parent, entries) { + * for (const entry of entries) { + * console.log(`Entry: ${entry.name}`); + * if (entry.isDirectory) { + * const entryPath = await join(parent, entry.name); + * await processEntriesRecursively(entryPath, await readDir(entryPath, { baseDir: BaseDirectory.AppLocalData })); + * } + * } + * } + * ``` + * + * @param path The path of the directory to read. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the list of entries in the directory. + * @since 2.0.0 + */ +async function readDir( + path: string | URL, + options?: ReadDirOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + return await invoke('plugin:fs|read_dir', { + path: path instanceof URL ? path.toString() : path, + options + }) +} + +/** + * Options for the functions that read a file, such as `readFile` and `readTextFile`. + * + * @since 2.0.0 + */ +interface ReadFileOptions { + /** Base directory for `path` */ + baseDir?: BaseDirectory + /** Text encoding to use when reading a text file. Defaults to 'utf-8'. */ + encoding?: string +} + +/** + * Reads and resolves to the entire contents of a file as an array of bytes. + * TextDecoder can be used to transform the bytes to string if required. + * @example + * ```typescript + * import { readFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const contents = await readFile('avatar.png', { baseDir: BaseDirectory.Resource }); + * ``` + * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the contents of the file as bytes. + * @since 2.0.0 + */ +async function readFile( + path: string | URL, + options?: ReadFileOptions +): Promise> { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const arr = await invoke('plugin:fs|read_file', { + path: path instanceof URL ? path.toString() : path, + options + }) + + return arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr) +} + +/** + * Reads and returns the entire contents of a file as a string using the specified encoding (default: UTF-8). + * @example + * ```typescript + * import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const contents = await readTextFile('app.conf', { baseDir: BaseDirectory.AppConfig }); + * ``` + * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path` and the text encoding. + * @returns A promise resolving to the contents of the file as a string. + * @since 2.0.0 + */ +async function readTextFile( + path: string | URL, + options?: ReadFileOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const arr = await invoke('plugin:fs|read_text_file', { + path: path instanceof URL ? path.toString() : path, + options + }) + + const bytes = arr instanceof ArrayBuffer ? arr : Uint8Array.from(arr) + + return new TextDecoder(options?.encoding ?? 'utf-8').decode(bytes) +} + +/** + * Returns an async {@linkcode AsyncIterableIterator} over the lines of a file, decoded using the specified encoding (default: UTF-8). + * @example + * ```typescript + * import { readTextFileLines, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const lines = await readTextFileLines('app.conf', { baseDir: BaseDirectory.AppConfig }); + * for await (const line of lines) { + * console.log(line); + * } + * ``` + * You could also call {@linkcode AsyncIterableIterator.next} to advance the + * iterator so you can lazily read the next line whenever you want. + * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path` and the text encoding. + * @returns A promise resolving to an iterator over the lines of the file. + * @since 2.0.0 + */ +async function readTextFileLines( + path: string | URL, + options?: ReadFileOptions +): Promise> { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const pathStr = path instanceof URL ? path.toString() : path + + return await Promise.resolve({ + path: pathStr, + rid: null as number | null, + + async next(): Promise> { + const decoder = new TextDecoder(options?.encoding ?? 'utf-8') + + if (this.rid === null) { + // Use the normalized encoding label for options. + const encoding = decoder.encoding + + this.rid = await invoke('plugin:fs|read_text_file_lines', { + path: pathStr, + options: options != null ? { ...options, encoding } : undefined + }) + } + + const arr = await invoke( + 'plugin:fs|read_text_file_lines_next', + { rid: this.rid } + ) + + const bytes = + arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr) + + // Rust side will never return an empty array for this command and + // ensure there is at least one elements there. + // + // This is an optimization to include whether we finished iteration or not (1 or 0) + // at the end of returned array to avoid serialization overhead of separate values. + const done = bytes[bytes.byteLength - 1] === 1 + + if (done) { + // a full iteration is over, reset rid for next iteration + this.rid = null + return { value: null, done } + } + + const line = decoder.decode(bytes.slice(0, bytes.byteLength - 1)) + + return { + value: line, + done + } + }, + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this + } + }) +} + +/** + * Options for the `remove` function, which deletes a file or a directory. + * + * @since 2.0.0 + */ +interface RemoveOptions { + /** Defaults to `false`. If set to `true`, path will be removed even if it's a non-empty directory. */ + recursive?: boolean + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Removes the named file or directory. + * If the directory is not empty and the `recursive` option isn't set to true, the promise will be rejected. + * @example + * ```typescript + * import { remove, BaseDirectory } from '@tauri-apps/plugin-fs'; + * await remove('users/file.txt', { baseDir: BaseDirectory.AppLocalData }); + * await remove('users', { baseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param path The path of the file or directory to remove. + * @param options Options defining the base directory of `path` and whether directories are removed recursively. + * @since 2.0.0 + */ +async function remove( + path: string | URL, + options?: RemoveOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|remove', { + path: path instanceof URL ? path.toString() : path, + options + }) +} + +/** + * Options for the `rename` function, defining the base directory of each path. + * + * @since 2.0.0 + */ +interface RenameOptions { + /** Base directory for `oldPath`. */ + oldPathBaseDir?: BaseDirectory + /** Base directory for `newPath`. */ + newPathBaseDir?: BaseDirectory +} + +/** + * Renames (moves) oldpath to newpath. Paths may be files or directories. + * If newpath already exists and is not a directory, rename() replaces it. + * OS-specific restrictions may apply when oldpath and newpath are in different directories. + * + * On Unix, this operation does not follow symlinks at either path. + * + * @example + * ```typescript + * import { rename, BaseDirectory } from '@tauri-apps/plugin-fs'; + * await rename('avatar.png', 'deleted.png', { oldPathBaseDir: BaseDirectory.App, newPathBaseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param oldPath The path of the file or directory to rename. + * @param newPath The path the file or directory is renamed to. + * @param options Options defining the base directory of each path. + * @since 2.0.0 + */ +async function rename( + oldPath: string | URL, + newPath: string | URL, + options?: RenameOptions +): Promise { + if ( + (oldPath instanceof URL && oldPath.protocol !== 'file:') + || (newPath instanceof URL && newPath.protocol !== 'file:') + ) { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|rename', { + oldPath: oldPath instanceof URL ? oldPath.toString() : oldPath, + newPath: newPath instanceof URL ? newPath.toString() : newPath, + options + }) +} + +/** + * Options for the `stat` and `lstat` functions, which read the metadata of a path. + * + * @since 2.0.0 + */ +interface StatOptions { + /** Base directory for `path`. */ + baseDir?: BaseDirectory +} + +/** + * Resolves to a {@linkcode FileInfo} for the specified `path`. Will always + * follow symlinks but will reject if the symlink points to a path outside of the scope. + * + * @example + * ```typescript + * import { stat, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const fileInfo = await stat("hello.txt", { baseDir: BaseDirectory.AppLocalData }); + * console.log(fileInfo.isFile); // true + * ``` + * + * @param path The path of the file or directory to inspect. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the metadata of the file or directory. + * @since 2.0.0 + */ +async function stat( + path: string | URL, + options?: StatOptions +): Promise { + const res = await invoke('plugin:fs|stat', { + path: path instanceof URL ? path.toString() : path, + options + }) + + return parseFileInfo(res) +} + +/** + * Resolves to a {@linkcode FileInfo} for the specified `path`. If `path` is a + * symlink, information for the symlink will be returned instead of what it + * points to. + * + * @example + * ```typescript + * import { lstat, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const fileInfo = await lstat("hello.txt", { baseDir: BaseDirectory.AppLocalData }); + * console.log(fileInfo.isFile); // true + * ``` + * + * @param path The path of the file, directory or symlink to inspect. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the metadata of the path itself. + * @since 2.0.0 + */ +async function lstat( + path: string | URL, + options?: StatOptions +): Promise { + const res = await invoke('plugin:fs|lstat', { + path: path instanceof URL ? path.toString() : path, + options + }) + + return parseFileInfo(res) +} + +/** + * Options for the `truncate` function, which truncates or extends a file. + * + * @since 2.0.0 + */ +interface TruncateOptions { + /** Base directory for `path`. */ + baseDir?: BaseDirectory +} + +/** + * Truncates or extends the specified file, to reach the specified `len`. + * If `len` is `0` or not specified, then the entire file contents are truncated. + * + * @example + * ```typescript + * import { truncate, readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * // truncate the entire file + * await truncate("my_file.txt", 0, { baseDir: BaseDirectory.AppLocalData }); + * + * // truncate part of the file + * const filePath = "file.txt"; + * await writeTextFile(filePath, "Hello World", { baseDir: BaseDirectory.AppLocalData }); + * await truncate(filePath, 7, { baseDir: BaseDirectory.AppLocalData }); + * const data = await readTextFile(filePath, { baseDir: BaseDirectory.AppLocalData }); + * console.log(data); // "Hello W" + * ``` + * + * @param path The path of the file to truncate or extend. + * @param len The length the file is resized to, in bytes. Defaults to `0`. + * @param options Options defining the base directory of `path`. + * @since 2.0.0 + */ +async function truncate( + path: string | URL, + len?: number, + options?: TruncateOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|truncate', { + path: path instanceof URL ? path.toString() : path, + len, + options + }) +} + +/** + * Options for the `writeFile` and `writeTextFile` functions, defining how the file is opened before writing to it. + * + * @since 2.0.0 + */ +interface WriteFileOptions { + /** Defaults to `false`. If set to `true`, will append to a file instead of overwriting previous contents. */ + append?: boolean + /** Sets the option to allow creating a new file, if one doesn't already exist at the specified path (defaults to `true`). */ + create?: boolean + /** Sets the option to create a new file, failing if it already exists. */ + createNew?: boolean + /** File permissions. Ignored on Windows. */ + mode?: number + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Write `data` to the given `path`, by default creating a new file if needed, else overwriting. + * @example + * ```typescript + * import { writeFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * let encoder = new TextEncoder(); + * let data = encoder.encode("Hello World"); + * await writeFile('file.txt', data, { baseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param path The path of the file to write to. + * @param data The bytes written to the file, either as a buffer or as a stream of chunks. + * @param options Options defining the base directory of `path` and how the file is opened. + * @since 2.0.0 + */ +async function writeFile( + path: string | URL, + data: Uint8Array | ReadableStream, + options?: WriteFileOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + if (data instanceof ReadableStream) { + const file = await open(path, { + read: false, + create: true, + write: true, + ...options + }) + const reader = data.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + await file.write(value) + } + } finally { + reader.releaseLock() + await file.close() + } + } else { + await invoke('plugin:fs|write_file', data, { + headers: { + path: encodeURIComponent(path instanceof URL ? path.toString() : path), + options: JSON.stringify(options) + } + }) + } +} + +/** + * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. + * + * @example + * ```typescript + * import { writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * await writeTextFile('file.txt', "Hello world", { baseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param path The path of the file to write to. + * @param data The UTF-8 string written to the file. + * @param options Options defining the base directory of `path` and how the file is opened. + * @since 2.0.0 + */ +async function writeTextFile( + path: string | URL, + data: string, + options?: WriteFileOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + const encoder = new TextEncoder() + + await invoke('plugin:fs|write_text_file', encoder.encode(data), { + headers: { + path: encodeURIComponent(path instanceof URL ? path.toString() : path), + options: JSON.stringify(options) + } + }) +} + +/** + * Options for the `exists` function, which checks whether a path exists. + * + * @since 2.0.0 + */ +interface ExistsOptions { + /** Base directory for `path`. */ + baseDir?: BaseDirectory +} + +/** + * Check if a path exists. + * @example + * ```typescript + * import { exists, BaseDirectory } from '@tauri-apps/plugin-fs'; + * // Check if the `$APPDATA/avatar.png` file exists + * await exists('avatar.png', { baseDir: BaseDirectory.AppData }); + * ``` + * + * @param path The path to check. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to `true` when the path exists, `false` otherwise. + * @since 2.0.0 + */ +async function exists( + path: string | URL, + options?: ExistsOptions +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + return await invoke('plugin:fs|exists', { + path: path instanceof URL ? path.toString() : path, + options + }) +} + +/** + * Options for the `watchImmediate` function, which reports file system changes as they happen. + * + * @since 2.0.0 + */ +interface WatchOptions { + /** Watch a directory recursively */ + recursive?: boolean + /** Base directory for `path` */ + baseDir?: BaseDirectory +} + +/** + * Options for the `watch` function, which reports file system changes after a debounce delay. + * + * @since 2.0.0 + */ +interface DebouncedWatchOptions extends WatchOptions { + /** + * The debounce delay in milliseconds. Changes that happen within this + * window are grouped and reported together. Defaults to `2000`. + */ + delayMs?: number +} + +/** + * A file system change reported to the callback of `watch` or `watchImmediate`. + * + * @since 2.0.0 + */ +interface WatchEvent { + /** The kind of change that was detected. */ + type: WatchEventKind + /** The paths affected by the change. */ + paths: string[] + /** Additional attributes reported by the underlying file system watcher. */ + attrs: unknown +} + +/** + * The kind of file system change described by a `WatchEvent`. + * + * @since 2.0.0 + */ +type WatchEventKind = + | 'any' + | { access: WatchEventKindAccess } + | { create: WatchEventKindCreate } + | { modify: WatchEventKindModify } + | { remove: WatchEventKindRemove } + | 'other' + +/** + * Describes how a file or directory was accessed. + * + * @since 2.0.0 + */ +type WatchEventKindAccess = + | { kind: 'any' } + | { kind: 'close'; mode: 'any' | 'execute' | 'read' | 'write' | 'other' } + | { kind: 'open'; mode: 'any' | 'execute' | 'read' | 'write' | 'other' } + | { kind: 'other' } + +/** + * Describes which kind of entry was created. + * + * @since 2.0.0 + */ +type WatchEventKindCreate = + | { kind: 'any' } + | { kind: 'file' } + | { kind: 'folder' } + | { kind: 'other' } + +/** + * Describes what was modified on a file or directory. + * + * @since 2.0.0 + */ +type WatchEventKindModify = + | { kind: 'any' } + | { kind: 'data'; mode: 'any' | 'size' | 'content' | 'other' } + | { + kind: 'metadata' + mode: + | 'any' + | 'access-time' + | 'write-time' + | 'permissions' + | 'ownership' + | 'extended' + | 'other' + } + | { kind: 'rename'; mode: 'any' | 'to' | 'from' | 'both' | 'other' } + | { kind: 'other' } + +/** + * Describes which kind of entry was removed. + * + * @since 2.0.0 + */ +type WatchEventKindRemove = + | { kind: 'any' } + | { kind: 'file' } + | { kind: 'folder' } + | { kind: 'other' } + +// TODO: Remove this in v3, return `Watcher` instead +/** + * Stops watching the paths it was created for. Returned by `watch` and `watchImmediate`. + * + * @since 2.0.0 + */ +type UnwatchFn = () => void + +class Watcher extends Resource {} + +async function watchInternal( + paths: string | string[] | URL | URL[], + cb: (event: WatchEvent) => void, + options: DebouncedWatchOptions +): Promise { + const watchPaths = Array.isArray(paths) ? paths : [paths] + + for (const path of watchPaths) { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + } + + const onEvent = new Channel() + onEvent.onmessage = cb + + const rid: number = await invoke('plugin:fs|watch', { + paths: watchPaths.map((p) => (p instanceof URL ? p.toString() : p)), + options, + onEvent + }) + + const watcher = new Watcher(rid) + + return () => { + void watcher.close() + } +} + +// TODO: Return `Watcher` instead in v3 +/** + * Watch changes (after a delay) on files or directories. + * + * Events that happen within the `delayMs` window are grouped and delivered in a single callback call. + * Requires the `watch` Cargo feature of the Rust plugin to be enabled. + * + * @example + * ```typescript + * import { watch, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * const unwatch = await watch( + * 'app.conf', + * (event) => console.log(event.type, event.paths), + * { baseDir: BaseDirectory.AppConfig, delayMs: 500 } + * ); + * + * // stop watching when you are done + * unwatch(); + * ``` + * + * @param paths The path or list of paths to watch. Each path can be a string or a `file://` URL. + * @param cb The callback executed for each batch of file system changes. + * @param options Options defining the base directory of the paths, the debounce delay and whether directories are watched recursively. + * @returns A promise resolving to a function that stops watching the given paths. + * @since 2.0.0 + */ +async function watch( + paths: string | string[] | URL | URL[], + cb: (event: WatchEvent) => void, + options?: DebouncedWatchOptions +): Promise { + return await watchInternal(paths, cb, { + delayMs: 2000, + ...options + }) +} + +// TODO: Return `Watcher` instead in v3 +/** + * Watch changes on files or directories. + * + * Unlike `watch`, changes are reported as soon as they are detected, without a debounce delay. + * Requires the `watch` Cargo feature of the Rust plugin to be enabled. + * + * @example + * ```typescript + * import { watchImmediate, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * const unwatch = await watchImmediate( + * 'logs', + * (event) => console.log(event.type, event.paths), + * { baseDir: BaseDirectory.AppLog, recursive: true } + * ); + * + * // stop watching when you are done + * unwatch(); + * ``` + * + * @param paths The path or list of paths to watch. Each path can be a string or a `file://` URL. + * @param cb The callback executed for each file system change. + * @param options Options defining the base directory of the paths and whether directories are watched recursively. + * @returns A promise resolving to a function that stops watching the given paths. + * @since 2.0.0 + */ +async function watchImmediate( + paths: string | string[] | URL | URL[], + cb: (event: WatchEvent) => void, + options?: WatchOptions +): Promise { + return await watchInternal(paths, cb, { + ...options, + delayMs: undefined + }) +} + +/** + * Get the size of a file or directory. For files, the `stat` functions can be used as well. + * + * If `path` is a directory, this function will recursively iterate over every file and every directory inside of `path` and therefore will be very time consuming if used on larger directories. + * + * @example + * ```typescript + * import { size, BaseDirectory } from '@tauri-apps/plugin-fs'; + * // Get the size of the `$APPDATA/tauri` directory. + * const dirSize = await size('tauri', { baseDir: BaseDirectory.AppData }); + * console.log(dirSize); // 1024 + * ``` + * + * @param path The path of the file or directory to measure. + * @returns A promise resolving to the size in bytes. + * @since 2.1.0 + */ +async function size(path: string | URL): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + return await invoke('plugin:fs|size', { + path: path instanceof URL ? path.toString() : path + }) +} + +/** + * Starts accessing a security-scoped resource for the given file URL. + * This should be called when you're accessing a file that was opened + * using a security-scoped URL (e.g., from a file picker). + * + * Note that accessing security-scoped resources is automatically managed by the plugin on iOS, so you don't need to call this function + * unless you want to manage the scope manually. + * + * You must call {@linkcode stopAccessingSecurityScopedResource} when you're done accessing the resource. + * + * #### Platform-specific + * + * - **iOS:** Starts accessing the security-scoped resource. + * - **Other platforms:** does nothing. + * + * @example + * ```typescript + * import { startAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; + * + * const filePath = 'file:///path/to/file.txt'; + * await startAccessingSecurityScopedResource(filePath); + * // ... use the resource ... + * ``` + * + * @param path The path or `file://` URL of the resource to start accessing. + * @since 2.5.0 + */ +async function startAccessingSecurityScopedResource( + path: string | URL +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|start_accessing_security_scoped_resource', { + path: path instanceof URL ? path.toString() : path + }) +} + +/** + * Stops accessing a security-scoped resource for the given file URL. + * This should be called when you're done accessing a file that was opened + * using a security-scoped URL (e.g., from a file picker) when using manual tracking via {@linkcode startAccessingSecurityScopedResource}. + * + * #### Platform-specific + * + * - **iOS:** Stops accessing the security-scoped resource. + * - **Other platforms:** does nothing. + * + * @example + * ```typescript + * import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs'; + * + * const filePath = 'file:///path/to/file.txt'; + * await startAccessingSecurityScopedResource(filePath); + * // ... use the resource ... + * // when you're done with the resource: + * await stopAccessingSecurityScopedResource(filePath); + * ``` + * + * @param path The path or `file://` URL of the resource to stop accessing. + * @since 2.5.0 + */ +async function stopAccessingSecurityScopedResource( + path: string | URL +): Promise { + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.') + } + + await invoke('plugin:fs|stop_accessing_security_scoped_resource', { + path: path instanceof URL ? path.toString() : path + }) +} + +export type { + CreateOptions, + OpenOptions, + CopyFileOptions, + MkdirOptions, + DirEntry, + ReadDirOptions, + ReadFileOptions, + RemoveOptions, + RenameOptions, + StatOptions, + TruncateOptions, + WriteFileOptions, + ExistsOptions, + FileInfo, + WatchOptions, + DebouncedWatchOptions, + WatchEvent, + WatchEventKind, + WatchEventKindAccess, + WatchEventKindCreate, + WatchEventKindModify, + WatchEventKindRemove, + UnwatchFn +} + +export { + BaseDirectory, + FileHandle, + create, + open, + copyFile, + mkdir, + readDir, + readFile, + readTextFile, + readTextFileLines, + remove, + rename, + SeekMode, + stat, + lstat, + truncate, + writeFile, + writeTextFile, + exists, + watch, + watchImmediate, + size, + startAccessingSecurityScopedResource, + stopAccessingSecurityScopedResource +} diff --git a/plugins/fs/package.json b/plugins/fs/package.json new file mode 100644 index 0000000..7fb9b3e --- /dev/null +++ b/plugins/fs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-fs", + "version": "2.5.2", + "description": "Access the file system.", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/fs/permissions/app.toml b/plugins/fs/permissions/app.toml new file mode 100644 index 0000000..aeb0521 --- /dev/null +++ b/plugins/fs/permissions/app.toml @@ -0,0 +1,114 @@ +"$schema" = "schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-app-recursive" +description = "This scope permits recursive access to the complete application folders, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPCONFIG" +[[permission.scope.allow]] +path = "$APPCONFIG/**" + +[[permission.scope.allow]] +path = "$APPDATA" +[[permission.scope.allow]] +path = "$APPDATA/**" + +[[permission.scope.allow]] +path = "$APPLOCALDATA" +[[permission.scope.allow]] +path = "$APPLOCALDATA/**" + +[[permission.scope.allow]] +path = "$APPCACHE" +[[permission.scope.allow]] +path = "$APPCACHE/**" + +[[permission.scope.allow]] +path = "$APPLOG" +[[permission.scope.allow]] +path = "$APPLOG/**" + +[[permission]] +identifier = "scope-app" +description = "This scope permits access to all files and list content of top level directories in the application folders." + +[[permission.scope.allow]] +path = "$APPCONFIG" +[[permission.scope.allow]] +path = "$APPCONFIG/*" + +[[permission.scope.allow]] +path = "$APPDATA" +[[permission.scope.allow]] +path = "$APPDATA/*" + +[[permission.scope.allow]] +path = "$APPLOCALDATA" +[[permission.scope.allow]] +path = "$APPLOCALDATA/*" + +[[permission.scope.allow]] +path = "$APPCACHE" +[[permission.scope.allow]] +path = "$APPCACHE/*" + +[[permission.scope.allow]] +path = "$APPLOG" +[[permission.scope.allow]] +path = "$APPLOG/*" + +[[permission]] +identifier = "scope-app-index" +description = "This scope permits to list all files and folders in the application directories." + +[[permission.scope.allow]] +path = "$APPCONFIG" + +[[permission.scope.allow]] +path = "$APPDATA" + +[[permission.scope.allow]] +path = "$APPLOCALDATA" + +[[permission.scope.allow]] +path = "$APPCACHE" + +[[permission.scope.allow]] +path = "$APPLOG" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-app-read-recursive" +description = "This allows full recursive read access to the complete application folders, files and subdirectories." +permissions = ["read-all", "scope-app-recursive"] + +[[set]] +identifier = "allow-app-write-recursive" +description = "This allows full recursive write access to the complete application folders, files and subdirectories." +permissions = ["write-all", "scope-app-recursive"] + +[[set]] +identifier = "allow-app-read" +description = "This allows non-recursive read access to the application folders." +permissions = ["read-all", "scope-app"] + +[[set]] +identifier = "allow-app-write" +description = "This allows non-recursive write access to the application folders." +permissions = ["write-all", "scope-app"] + +[[set]] +identifier = "allow-app-meta-recursive" +description = "This allows full recursive read access to metadata of the application folders, including file listing and statistics." +permissions = ["read-meta", "scope-app-recursive"] + +[[set]] +identifier = "allow-app-meta" +description = "This allows non-recursive read access to metadata of the application folders, including file listing and statistics." +permissions = ["read-meta", "scope-app-index"] diff --git a/plugins/fs/permissions/autogenerated/base-directories/appcache.toml b/plugins/fs/permissions/autogenerated/base-directories/appcache.toml new file mode 100644 index 0000000..50e19ef --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/appcache.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-appcache-recursive" +description = "This scope permits recursive access to the complete `$APPCACHE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPCACHE" +[[permission.scope.allow]] +path = "$APPCACHE/**" + +[[permission]] +identifier = "scope-appcache" +description = "This scope permits access to all files and list content of top level directories in the `$APPCACHE` folder." + +[[permission.scope.allow]] +path = "$APPCACHE" +[[permission.scope.allow]] +path = "$APPCACHE/*" + +[[permission]] +identifier = "scope-appcache-index" +description = "This scope permits to list all files and folders in the `$APPCACHE`folder." + +[[permission.scope.allow]] +path = "$APPCACHE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-appcache-read-recursive" +description = "This allows full recursive read access to the complete `$APPCACHE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-appcache-recursive" +] + +[[set]] +identifier = "allow-appcache-write-recursive" +description = "This allows full recursive write access to the complete `$APPCACHE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-appcache-recursive" +] + +[[set]] +identifier = "allow-appcache-read" +description = "This allows non-recursive read access to the `$APPCACHE` folder." +permissions = [ + "read-all", + "scope-appcache" +] + +[[set]] +identifier = "allow-appcache-write" +description = "This allows non-recursive write access to the `$APPCACHE` folder." +permissions = [ + "write-all", + "scope-appcache" +] + +[[set]] +identifier = "allow-appcache-meta-recursive" +description = "This allows full recursive read access to metadata of the `$APPCACHE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appcache-recursive" +] + +[[set]] +identifier = "allow-appcache-meta" +description = "This allows non-recursive read access to metadata of the `$APPCACHE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appcache-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/appconfig.toml b/plugins/fs/permissions/autogenerated/base-directories/appconfig.toml new file mode 100644 index 0000000..ab13695 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/appconfig.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-appconfig-recursive" +description = "This scope permits recursive access to the complete `$APPCONFIG` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPCONFIG" +[[permission.scope.allow]] +path = "$APPCONFIG/**" + +[[permission]] +identifier = "scope-appconfig" +description = "This scope permits access to all files and list content of top level directories in the `$APPCONFIG` folder." + +[[permission.scope.allow]] +path = "$APPCONFIG" +[[permission.scope.allow]] +path = "$APPCONFIG/*" + +[[permission]] +identifier = "scope-appconfig-index" +description = "This scope permits to list all files and folders in the `$APPCONFIG`folder." + +[[permission.scope.allow]] +path = "$APPCONFIG" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-appconfig-read-recursive" +description = "This allows full recursive read access to the complete `$APPCONFIG` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-appconfig-recursive" +] + +[[set]] +identifier = "allow-appconfig-write-recursive" +description = "This allows full recursive write access to the complete `$APPCONFIG` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-appconfig-recursive" +] + +[[set]] +identifier = "allow-appconfig-read" +description = "This allows non-recursive read access to the `$APPCONFIG` folder." +permissions = [ + "read-all", + "scope-appconfig" +] + +[[set]] +identifier = "allow-appconfig-write" +description = "This allows non-recursive write access to the `$APPCONFIG` folder." +permissions = [ + "write-all", + "scope-appconfig" +] + +[[set]] +identifier = "allow-appconfig-meta-recursive" +description = "This allows full recursive read access to metadata of the `$APPCONFIG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appconfig-recursive" +] + +[[set]] +identifier = "allow-appconfig-meta" +description = "This allows non-recursive read access to metadata of the `$APPCONFIG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appconfig-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/appdata.toml b/plugins/fs/permissions/autogenerated/base-directories/appdata.toml new file mode 100644 index 0000000..1b0931e --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/appdata.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-appdata-recursive" +description = "This scope permits recursive access to the complete `$APPDATA` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPDATA" +[[permission.scope.allow]] +path = "$APPDATA/**" + +[[permission]] +identifier = "scope-appdata" +description = "This scope permits access to all files and list content of top level directories in the `$APPDATA` folder." + +[[permission.scope.allow]] +path = "$APPDATA" +[[permission.scope.allow]] +path = "$APPDATA/*" + +[[permission]] +identifier = "scope-appdata-index" +description = "This scope permits to list all files and folders in the `$APPDATA`folder." + +[[permission.scope.allow]] +path = "$APPDATA" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-appdata-read-recursive" +description = "This allows full recursive read access to the complete `$APPDATA` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-appdata-recursive" +] + +[[set]] +identifier = "allow-appdata-write-recursive" +description = "This allows full recursive write access to the complete `$APPDATA` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-appdata-recursive" +] + +[[set]] +identifier = "allow-appdata-read" +description = "This allows non-recursive read access to the `$APPDATA` folder." +permissions = [ + "read-all", + "scope-appdata" +] + +[[set]] +identifier = "allow-appdata-write" +description = "This allows non-recursive write access to the `$APPDATA` folder." +permissions = [ + "write-all", + "scope-appdata" +] + +[[set]] +identifier = "allow-appdata-meta-recursive" +description = "This allows full recursive read access to metadata of the `$APPDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appdata-recursive" +] + +[[set]] +identifier = "allow-appdata-meta" +description = "This allows non-recursive read access to metadata of the `$APPDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-appdata-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/applocaldata.toml b/plugins/fs/permissions/autogenerated/base-directories/applocaldata.toml new file mode 100644 index 0000000..a6e38a3 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/applocaldata.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-applocaldata-recursive" +description = "This scope permits recursive access to the complete `$APPLOCALDATA` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPLOCALDATA" +[[permission.scope.allow]] +path = "$APPLOCALDATA/**" + +[[permission]] +identifier = "scope-applocaldata" +description = "This scope permits access to all files and list content of top level directories in the `$APPLOCALDATA` folder." + +[[permission.scope.allow]] +path = "$APPLOCALDATA" +[[permission.scope.allow]] +path = "$APPLOCALDATA/*" + +[[permission]] +identifier = "scope-applocaldata-index" +description = "This scope permits to list all files and folders in the `$APPLOCALDATA`folder." + +[[permission.scope.allow]] +path = "$APPLOCALDATA" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-applocaldata-read-recursive" +description = "This allows full recursive read access to the complete `$APPLOCALDATA` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-applocaldata-recursive" +] + +[[set]] +identifier = "allow-applocaldata-write-recursive" +description = "This allows full recursive write access to the complete `$APPLOCALDATA` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-applocaldata-recursive" +] + +[[set]] +identifier = "allow-applocaldata-read" +description = "This allows non-recursive read access to the `$APPLOCALDATA` folder." +permissions = [ + "read-all", + "scope-applocaldata" +] + +[[set]] +identifier = "allow-applocaldata-write" +description = "This allows non-recursive write access to the `$APPLOCALDATA` folder." +permissions = [ + "write-all", + "scope-applocaldata" +] + +[[set]] +identifier = "allow-applocaldata-meta-recursive" +description = "This allows full recursive read access to metadata of the `$APPLOCALDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-applocaldata-recursive" +] + +[[set]] +identifier = "allow-applocaldata-meta" +description = "This allows non-recursive read access to metadata of the `$APPLOCALDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-applocaldata-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/applog.toml b/plugins/fs/permissions/autogenerated/base-directories/applog.toml new file mode 100644 index 0000000..a979ce7 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/applog.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-applog-recursive" +description = "This scope permits recursive access to the complete `$APPLOG` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$APPLOG" +[[permission.scope.allow]] +path = "$APPLOG/**" + +[[permission]] +identifier = "scope-applog" +description = "This scope permits access to all files and list content of top level directories in the `$APPLOG` folder." + +[[permission.scope.allow]] +path = "$APPLOG" +[[permission.scope.allow]] +path = "$APPLOG/*" + +[[permission]] +identifier = "scope-applog-index" +description = "This scope permits to list all files and folders in the `$APPLOG`folder." + +[[permission.scope.allow]] +path = "$APPLOG" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-applog-read-recursive" +description = "This allows full recursive read access to the complete `$APPLOG` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-applog-recursive" +] + +[[set]] +identifier = "allow-applog-write-recursive" +description = "This allows full recursive write access to the complete `$APPLOG` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-applog-recursive" +] + +[[set]] +identifier = "allow-applog-read" +description = "This allows non-recursive read access to the `$APPLOG` folder." +permissions = [ + "read-all", + "scope-applog" +] + +[[set]] +identifier = "allow-applog-write" +description = "This allows non-recursive write access to the `$APPLOG` folder." +permissions = [ + "write-all", + "scope-applog" +] + +[[set]] +identifier = "allow-applog-meta-recursive" +description = "This allows full recursive read access to metadata of the `$APPLOG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-applog-recursive" +] + +[[set]] +identifier = "allow-applog-meta" +description = "This allows non-recursive read access to metadata of the `$APPLOG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-applog-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/audio.toml b/plugins/fs/permissions/autogenerated/base-directories/audio.toml new file mode 100644 index 0000000..d66d68a --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/audio.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-audio-recursive" +description = "This scope permits recursive access to the complete `$AUDIO` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$AUDIO" +[[permission.scope.allow]] +path = "$AUDIO/**" + +[[permission]] +identifier = "scope-audio" +description = "This scope permits access to all files and list content of top level directories in the `$AUDIO` folder." + +[[permission.scope.allow]] +path = "$AUDIO" +[[permission.scope.allow]] +path = "$AUDIO/*" + +[[permission]] +identifier = "scope-audio-index" +description = "This scope permits to list all files and folders in the `$AUDIO`folder." + +[[permission.scope.allow]] +path = "$AUDIO" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-audio-read-recursive" +description = "This allows full recursive read access to the complete `$AUDIO` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-audio-recursive" +] + +[[set]] +identifier = "allow-audio-write-recursive" +description = "This allows full recursive write access to the complete `$AUDIO` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-audio-recursive" +] + +[[set]] +identifier = "allow-audio-read" +description = "This allows non-recursive read access to the `$AUDIO` folder." +permissions = [ + "read-all", + "scope-audio" +] + +[[set]] +identifier = "allow-audio-write" +description = "This allows non-recursive write access to the `$AUDIO` folder." +permissions = [ + "write-all", + "scope-audio" +] + +[[set]] +identifier = "allow-audio-meta-recursive" +description = "This allows full recursive read access to metadata of the `$AUDIO` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-audio-recursive" +] + +[[set]] +identifier = "allow-audio-meta" +description = "This allows non-recursive read access to metadata of the `$AUDIO` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-audio-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/cache.toml b/plugins/fs/permissions/autogenerated/base-directories/cache.toml new file mode 100644 index 0000000..814319e --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/cache.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-cache-recursive" +description = "This scope permits recursive access to the complete `$CACHE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$CACHE" +[[permission.scope.allow]] +path = "$CACHE/**" + +[[permission]] +identifier = "scope-cache" +description = "This scope permits access to all files and list content of top level directories in the `$CACHE` folder." + +[[permission.scope.allow]] +path = "$CACHE" +[[permission.scope.allow]] +path = "$CACHE/*" + +[[permission]] +identifier = "scope-cache-index" +description = "This scope permits to list all files and folders in the `$CACHE`folder." + +[[permission.scope.allow]] +path = "$CACHE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-cache-read-recursive" +description = "This allows full recursive read access to the complete `$CACHE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-cache-recursive" +] + +[[set]] +identifier = "allow-cache-write-recursive" +description = "This allows full recursive write access to the complete `$CACHE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-cache-recursive" +] + +[[set]] +identifier = "allow-cache-read" +description = "This allows non-recursive read access to the `$CACHE` folder." +permissions = [ + "read-all", + "scope-cache" +] + +[[set]] +identifier = "allow-cache-write" +description = "This allows non-recursive write access to the `$CACHE` folder." +permissions = [ + "write-all", + "scope-cache" +] + +[[set]] +identifier = "allow-cache-meta-recursive" +description = "This allows full recursive read access to metadata of the `$CACHE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-cache-recursive" +] + +[[set]] +identifier = "allow-cache-meta" +description = "This allows non-recursive read access to metadata of the `$CACHE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-cache-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/config.toml b/plugins/fs/permissions/autogenerated/base-directories/config.toml new file mode 100644 index 0000000..5922104 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/config.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-config-recursive" +description = "This scope permits recursive access to the complete `$CONFIG` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$CONFIG" +[[permission.scope.allow]] +path = "$CONFIG/**" + +[[permission]] +identifier = "scope-config" +description = "This scope permits access to all files and list content of top level directories in the `$CONFIG` folder." + +[[permission.scope.allow]] +path = "$CONFIG" +[[permission.scope.allow]] +path = "$CONFIG/*" + +[[permission]] +identifier = "scope-config-index" +description = "This scope permits to list all files and folders in the `$CONFIG`folder." + +[[permission.scope.allow]] +path = "$CONFIG" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-config-read-recursive" +description = "This allows full recursive read access to the complete `$CONFIG` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-config-recursive" +] + +[[set]] +identifier = "allow-config-write-recursive" +description = "This allows full recursive write access to the complete `$CONFIG` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-config-recursive" +] + +[[set]] +identifier = "allow-config-read" +description = "This allows non-recursive read access to the `$CONFIG` folder." +permissions = [ + "read-all", + "scope-config" +] + +[[set]] +identifier = "allow-config-write" +description = "This allows non-recursive write access to the `$CONFIG` folder." +permissions = [ + "write-all", + "scope-config" +] + +[[set]] +identifier = "allow-config-meta-recursive" +description = "This allows full recursive read access to metadata of the `$CONFIG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-config-recursive" +] + +[[set]] +identifier = "allow-config-meta" +description = "This allows non-recursive read access to metadata of the `$CONFIG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-config-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/data.toml b/plugins/fs/permissions/autogenerated/base-directories/data.toml new file mode 100644 index 0000000..a8428ca --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/data.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-data-recursive" +description = "This scope permits recursive access to the complete `$DATA` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$DATA" +[[permission.scope.allow]] +path = "$DATA/**" + +[[permission]] +identifier = "scope-data" +description = "This scope permits access to all files and list content of top level directories in the `$DATA` folder." + +[[permission.scope.allow]] +path = "$DATA" +[[permission.scope.allow]] +path = "$DATA/*" + +[[permission]] +identifier = "scope-data-index" +description = "This scope permits to list all files and folders in the `$DATA`folder." + +[[permission.scope.allow]] +path = "$DATA" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-data-read-recursive" +description = "This allows full recursive read access to the complete `$DATA` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-data-recursive" +] + +[[set]] +identifier = "allow-data-write-recursive" +description = "This allows full recursive write access to the complete `$DATA` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-data-recursive" +] + +[[set]] +identifier = "allow-data-read" +description = "This allows non-recursive read access to the `$DATA` folder." +permissions = [ + "read-all", + "scope-data" +] + +[[set]] +identifier = "allow-data-write" +description = "This allows non-recursive write access to the `$DATA` folder." +permissions = [ + "write-all", + "scope-data" +] + +[[set]] +identifier = "allow-data-meta-recursive" +description = "This allows full recursive read access to metadata of the `$DATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-data-recursive" +] + +[[set]] +identifier = "allow-data-meta" +description = "This allows non-recursive read access to metadata of the `$DATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-data-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/desktop.toml b/plugins/fs/permissions/autogenerated/base-directories/desktop.toml new file mode 100644 index 0000000..da369fa --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/desktop.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-desktop-recursive" +description = "This scope permits recursive access to the complete `$DESKTOP` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$DESKTOP" +[[permission.scope.allow]] +path = "$DESKTOP/**" + +[[permission]] +identifier = "scope-desktop" +description = "This scope permits access to all files and list content of top level directories in the `$DESKTOP` folder." + +[[permission.scope.allow]] +path = "$DESKTOP" +[[permission.scope.allow]] +path = "$DESKTOP/*" + +[[permission]] +identifier = "scope-desktop-index" +description = "This scope permits to list all files and folders in the `$DESKTOP`folder." + +[[permission.scope.allow]] +path = "$DESKTOP" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-desktop-read-recursive" +description = "This allows full recursive read access to the complete `$DESKTOP` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-desktop-recursive" +] + +[[set]] +identifier = "allow-desktop-write-recursive" +description = "This allows full recursive write access to the complete `$DESKTOP` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-desktop-recursive" +] + +[[set]] +identifier = "allow-desktop-read" +description = "This allows non-recursive read access to the `$DESKTOP` folder." +permissions = [ + "read-all", + "scope-desktop" +] + +[[set]] +identifier = "allow-desktop-write" +description = "This allows non-recursive write access to the `$DESKTOP` folder." +permissions = [ + "write-all", + "scope-desktop" +] + +[[set]] +identifier = "allow-desktop-meta-recursive" +description = "This allows full recursive read access to metadata of the `$DESKTOP` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-desktop-recursive" +] + +[[set]] +identifier = "allow-desktop-meta" +description = "This allows non-recursive read access to metadata of the `$DESKTOP` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-desktop-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/document.toml b/plugins/fs/permissions/autogenerated/base-directories/document.toml new file mode 100644 index 0000000..9feb4d0 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/document.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-document-recursive" +description = "This scope permits recursive access to the complete `$DOCUMENT` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$DOCUMENT" +[[permission.scope.allow]] +path = "$DOCUMENT/**" + +[[permission]] +identifier = "scope-document" +description = "This scope permits access to all files and list content of top level directories in the `$DOCUMENT` folder." + +[[permission.scope.allow]] +path = "$DOCUMENT" +[[permission.scope.allow]] +path = "$DOCUMENT/*" + +[[permission]] +identifier = "scope-document-index" +description = "This scope permits to list all files and folders in the `$DOCUMENT`folder." + +[[permission.scope.allow]] +path = "$DOCUMENT" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-document-read-recursive" +description = "This allows full recursive read access to the complete `$DOCUMENT` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-document-recursive" +] + +[[set]] +identifier = "allow-document-write-recursive" +description = "This allows full recursive write access to the complete `$DOCUMENT` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-document-recursive" +] + +[[set]] +identifier = "allow-document-read" +description = "This allows non-recursive read access to the `$DOCUMENT` folder." +permissions = [ + "read-all", + "scope-document" +] + +[[set]] +identifier = "allow-document-write" +description = "This allows non-recursive write access to the `$DOCUMENT` folder." +permissions = [ + "write-all", + "scope-document" +] + +[[set]] +identifier = "allow-document-meta-recursive" +description = "This allows full recursive read access to metadata of the `$DOCUMENT` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-document-recursive" +] + +[[set]] +identifier = "allow-document-meta" +description = "This allows non-recursive read access to metadata of the `$DOCUMENT` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-document-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/download.toml b/plugins/fs/permissions/autogenerated/base-directories/download.toml new file mode 100644 index 0000000..8659e3a --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/download.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-download-recursive" +description = "This scope permits recursive access to the complete `$DOWNLOAD` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$DOWNLOAD" +[[permission.scope.allow]] +path = "$DOWNLOAD/**" + +[[permission]] +identifier = "scope-download" +description = "This scope permits access to all files and list content of top level directories in the `$DOWNLOAD` folder." + +[[permission.scope.allow]] +path = "$DOWNLOAD" +[[permission.scope.allow]] +path = "$DOWNLOAD/*" + +[[permission]] +identifier = "scope-download-index" +description = "This scope permits to list all files and folders in the `$DOWNLOAD`folder." + +[[permission.scope.allow]] +path = "$DOWNLOAD" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-download-read-recursive" +description = "This allows full recursive read access to the complete `$DOWNLOAD` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-download-recursive" +] + +[[set]] +identifier = "allow-download-write-recursive" +description = "This allows full recursive write access to the complete `$DOWNLOAD` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-download-recursive" +] + +[[set]] +identifier = "allow-download-read" +description = "This allows non-recursive read access to the `$DOWNLOAD` folder." +permissions = [ + "read-all", + "scope-download" +] + +[[set]] +identifier = "allow-download-write" +description = "This allows non-recursive write access to the `$DOWNLOAD` folder." +permissions = [ + "write-all", + "scope-download" +] + +[[set]] +identifier = "allow-download-meta-recursive" +description = "This allows full recursive read access to metadata of the `$DOWNLOAD` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-download-recursive" +] + +[[set]] +identifier = "allow-download-meta" +description = "This allows non-recursive read access to metadata of the `$DOWNLOAD` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-download-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/exe.toml b/plugins/fs/permissions/autogenerated/base-directories/exe.toml new file mode 100644 index 0000000..94950e8 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/exe.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-exe-recursive" +description = "This scope permits recursive access to the complete `$EXE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$EXE" +[[permission.scope.allow]] +path = "$EXE/**" + +[[permission]] +identifier = "scope-exe" +description = "This scope permits access to all files and list content of top level directories in the `$EXE` folder." + +[[permission.scope.allow]] +path = "$EXE" +[[permission.scope.allow]] +path = "$EXE/*" + +[[permission]] +identifier = "scope-exe-index" +description = "This scope permits to list all files and folders in the `$EXE`folder." + +[[permission.scope.allow]] +path = "$EXE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-exe-read-recursive" +description = "This allows full recursive read access to the complete `$EXE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-exe-recursive" +] + +[[set]] +identifier = "allow-exe-write-recursive" +description = "This allows full recursive write access to the complete `$EXE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-exe-recursive" +] + +[[set]] +identifier = "allow-exe-read" +description = "This allows non-recursive read access to the `$EXE` folder." +permissions = [ + "read-all", + "scope-exe" +] + +[[set]] +identifier = "allow-exe-write" +description = "This allows non-recursive write access to the `$EXE` folder." +permissions = [ + "write-all", + "scope-exe" +] + +[[set]] +identifier = "allow-exe-meta-recursive" +description = "This allows full recursive read access to metadata of the `$EXE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-exe-recursive" +] + +[[set]] +identifier = "allow-exe-meta" +description = "This allows non-recursive read access to metadata of the `$EXE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-exe-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/font.toml b/plugins/fs/permissions/autogenerated/base-directories/font.toml new file mode 100644 index 0000000..2184004 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/font.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-font-recursive" +description = "This scope permits recursive access to the complete `$FONT` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$FONT" +[[permission.scope.allow]] +path = "$FONT/**" + +[[permission]] +identifier = "scope-font" +description = "This scope permits access to all files and list content of top level directories in the `$FONT` folder." + +[[permission.scope.allow]] +path = "$FONT" +[[permission.scope.allow]] +path = "$FONT/*" + +[[permission]] +identifier = "scope-font-index" +description = "This scope permits to list all files and folders in the `$FONT`folder." + +[[permission.scope.allow]] +path = "$FONT" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-font-read-recursive" +description = "This allows full recursive read access to the complete `$FONT` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-font-recursive" +] + +[[set]] +identifier = "allow-font-write-recursive" +description = "This allows full recursive write access to the complete `$FONT` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-font-recursive" +] + +[[set]] +identifier = "allow-font-read" +description = "This allows non-recursive read access to the `$FONT` folder." +permissions = [ + "read-all", + "scope-font" +] + +[[set]] +identifier = "allow-font-write" +description = "This allows non-recursive write access to the `$FONT` folder." +permissions = [ + "write-all", + "scope-font" +] + +[[set]] +identifier = "allow-font-meta-recursive" +description = "This allows full recursive read access to metadata of the `$FONT` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-font-recursive" +] + +[[set]] +identifier = "allow-font-meta" +description = "This allows non-recursive read access to metadata of the `$FONT` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-font-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/home.toml b/plugins/fs/permissions/autogenerated/base-directories/home.toml new file mode 100644 index 0000000..cbf48e2 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/home.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-home-recursive" +description = "This scope permits recursive access to the complete `$HOME` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$HOME" +[[permission.scope.allow]] +path = "$HOME/**" + +[[permission]] +identifier = "scope-home" +description = "This scope permits access to all files and list content of top level directories in the `$HOME` folder." + +[[permission.scope.allow]] +path = "$HOME" +[[permission.scope.allow]] +path = "$HOME/*" + +[[permission]] +identifier = "scope-home-index" +description = "This scope permits to list all files and folders in the `$HOME`folder." + +[[permission.scope.allow]] +path = "$HOME" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-home-read-recursive" +description = "This allows full recursive read access to the complete `$HOME` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-home-recursive" +] + +[[set]] +identifier = "allow-home-write-recursive" +description = "This allows full recursive write access to the complete `$HOME` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-home-recursive" +] + +[[set]] +identifier = "allow-home-read" +description = "This allows non-recursive read access to the `$HOME` folder." +permissions = [ + "read-all", + "scope-home" +] + +[[set]] +identifier = "allow-home-write" +description = "This allows non-recursive write access to the `$HOME` folder." +permissions = [ + "write-all", + "scope-home" +] + +[[set]] +identifier = "allow-home-meta-recursive" +description = "This allows full recursive read access to metadata of the `$HOME` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-home-recursive" +] + +[[set]] +identifier = "allow-home-meta" +description = "This allows non-recursive read access to metadata of the `$HOME` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-home-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/localdata.toml b/plugins/fs/permissions/autogenerated/base-directories/localdata.toml new file mode 100644 index 0000000..90a8f48 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/localdata.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-localdata-recursive" +description = "This scope permits recursive access to the complete `$LOCALDATA` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$LOCALDATA" +[[permission.scope.allow]] +path = "$LOCALDATA/**" + +[[permission]] +identifier = "scope-localdata" +description = "This scope permits access to all files and list content of top level directories in the `$LOCALDATA` folder." + +[[permission.scope.allow]] +path = "$LOCALDATA" +[[permission.scope.allow]] +path = "$LOCALDATA/*" + +[[permission]] +identifier = "scope-localdata-index" +description = "This scope permits to list all files and folders in the `$LOCALDATA`folder." + +[[permission.scope.allow]] +path = "$LOCALDATA" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-localdata-read-recursive" +description = "This allows full recursive read access to the complete `$LOCALDATA` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-localdata-recursive" +] + +[[set]] +identifier = "allow-localdata-write-recursive" +description = "This allows full recursive write access to the complete `$LOCALDATA` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-localdata-recursive" +] + +[[set]] +identifier = "allow-localdata-read" +description = "This allows non-recursive read access to the `$LOCALDATA` folder." +permissions = [ + "read-all", + "scope-localdata" +] + +[[set]] +identifier = "allow-localdata-write" +description = "This allows non-recursive write access to the `$LOCALDATA` folder." +permissions = [ + "write-all", + "scope-localdata" +] + +[[set]] +identifier = "allow-localdata-meta-recursive" +description = "This allows full recursive read access to metadata of the `$LOCALDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-localdata-recursive" +] + +[[set]] +identifier = "allow-localdata-meta" +description = "This allows non-recursive read access to metadata of the `$LOCALDATA` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-localdata-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/log.toml b/plugins/fs/permissions/autogenerated/base-directories/log.toml new file mode 100644 index 0000000..d505a3c --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/log.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-log-recursive" +description = "This scope permits recursive access to the complete `$LOG` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$LOG" +[[permission.scope.allow]] +path = "$LOG/**" + +[[permission]] +identifier = "scope-log" +description = "This scope permits access to all files and list content of top level directories in the `$LOG` folder." + +[[permission.scope.allow]] +path = "$LOG" +[[permission.scope.allow]] +path = "$LOG/*" + +[[permission]] +identifier = "scope-log-index" +description = "This scope permits to list all files and folders in the `$LOG`folder." + +[[permission.scope.allow]] +path = "$LOG" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-log-read-recursive" +description = "This allows full recursive read access to the complete `$LOG` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-log-recursive" +] + +[[set]] +identifier = "allow-log-write-recursive" +description = "This allows full recursive write access to the complete `$LOG` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-log-recursive" +] + +[[set]] +identifier = "allow-log-read" +description = "This allows non-recursive read access to the `$LOG` folder." +permissions = [ + "read-all", + "scope-log" +] + +[[set]] +identifier = "allow-log-write" +description = "This allows non-recursive write access to the `$LOG` folder." +permissions = [ + "write-all", + "scope-log" +] + +[[set]] +identifier = "allow-log-meta-recursive" +description = "This allows full recursive read access to metadata of the `$LOG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-log-recursive" +] + +[[set]] +identifier = "allow-log-meta" +description = "This allows non-recursive read access to metadata of the `$LOG` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-log-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/picture.toml b/plugins/fs/permissions/autogenerated/base-directories/picture.toml new file mode 100644 index 0000000..6a76090 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/picture.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-picture-recursive" +description = "This scope permits recursive access to the complete `$PICTURE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$PICTURE" +[[permission.scope.allow]] +path = "$PICTURE/**" + +[[permission]] +identifier = "scope-picture" +description = "This scope permits access to all files and list content of top level directories in the `$PICTURE` folder." + +[[permission.scope.allow]] +path = "$PICTURE" +[[permission.scope.allow]] +path = "$PICTURE/*" + +[[permission]] +identifier = "scope-picture-index" +description = "This scope permits to list all files and folders in the `$PICTURE`folder." + +[[permission.scope.allow]] +path = "$PICTURE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-picture-read-recursive" +description = "This allows full recursive read access to the complete `$PICTURE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-picture-recursive" +] + +[[set]] +identifier = "allow-picture-write-recursive" +description = "This allows full recursive write access to the complete `$PICTURE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-picture-recursive" +] + +[[set]] +identifier = "allow-picture-read" +description = "This allows non-recursive read access to the `$PICTURE` folder." +permissions = [ + "read-all", + "scope-picture" +] + +[[set]] +identifier = "allow-picture-write" +description = "This allows non-recursive write access to the `$PICTURE` folder." +permissions = [ + "write-all", + "scope-picture" +] + +[[set]] +identifier = "allow-picture-meta-recursive" +description = "This allows full recursive read access to metadata of the `$PICTURE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-picture-recursive" +] + +[[set]] +identifier = "allow-picture-meta" +description = "This allows non-recursive read access to metadata of the `$PICTURE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-picture-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/public.toml b/plugins/fs/permissions/autogenerated/base-directories/public.toml new file mode 100644 index 0000000..2e39abb --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/public.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-public-recursive" +description = "This scope permits recursive access to the complete `$PUBLIC` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$PUBLIC" +[[permission.scope.allow]] +path = "$PUBLIC/**" + +[[permission]] +identifier = "scope-public" +description = "This scope permits access to all files and list content of top level directories in the `$PUBLIC` folder." + +[[permission.scope.allow]] +path = "$PUBLIC" +[[permission.scope.allow]] +path = "$PUBLIC/*" + +[[permission]] +identifier = "scope-public-index" +description = "This scope permits to list all files and folders in the `$PUBLIC`folder." + +[[permission.scope.allow]] +path = "$PUBLIC" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-public-read-recursive" +description = "This allows full recursive read access to the complete `$PUBLIC` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-public-recursive" +] + +[[set]] +identifier = "allow-public-write-recursive" +description = "This allows full recursive write access to the complete `$PUBLIC` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-public-recursive" +] + +[[set]] +identifier = "allow-public-read" +description = "This allows non-recursive read access to the `$PUBLIC` folder." +permissions = [ + "read-all", + "scope-public" +] + +[[set]] +identifier = "allow-public-write" +description = "This allows non-recursive write access to the `$PUBLIC` folder." +permissions = [ + "write-all", + "scope-public" +] + +[[set]] +identifier = "allow-public-meta-recursive" +description = "This allows full recursive read access to metadata of the `$PUBLIC` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-public-recursive" +] + +[[set]] +identifier = "allow-public-meta" +description = "This allows non-recursive read access to metadata of the `$PUBLIC` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-public-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/resource.toml b/plugins/fs/permissions/autogenerated/base-directories/resource.toml new file mode 100644 index 0000000..53dfeb0 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/resource.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-resource-recursive" +description = "This scope permits recursive access to the complete `$RESOURCE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$RESOURCE" +[[permission.scope.allow]] +path = "$RESOURCE/**" + +[[permission]] +identifier = "scope-resource" +description = "This scope permits access to all files and list content of top level directories in the `$RESOURCE` folder." + +[[permission.scope.allow]] +path = "$RESOURCE" +[[permission.scope.allow]] +path = "$RESOURCE/*" + +[[permission]] +identifier = "scope-resource-index" +description = "This scope permits to list all files and folders in the `$RESOURCE`folder." + +[[permission.scope.allow]] +path = "$RESOURCE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-resource-read-recursive" +description = "This allows full recursive read access to the complete `$RESOURCE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-resource-recursive" +] + +[[set]] +identifier = "allow-resource-write-recursive" +description = "This allows full recursive write access to the complete `$RESOURCE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-resource-recursive" +] + +[[set]] +identifier = "allow-resource-read" +description = "This allows non-recursive read access to the `$RESOURCE` folder." +permissions = [ + "read-all", + "scope-resource" +] + +[[set]] +identifier = "allow-resource-write" +description = "This allows non-recursive write access to the `$RESOURCE` folder." +permissions = [ + "write-all", + "scope-resource" +] + +[[set]] +identifier = "allow-resource-meta-recursive" +description = "This allows full recursive read access to metadata of the `$RESOURCE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-resource-recursive" +] + +[[set]] +identifier = "allow-resource-meta" +description = "This allows non-recursive read access to metadata of the `$RESOURCE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-resource-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/runtime.toml b/plugins/fs/permissions/autogenerated/base-directories/runtime.toml new file mode 100644 index 0000000..8dcc2a0 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/runtime.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-runtime-recursive" +description = "This scope permits recursive access to the complete `$RUNTIME` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$RUNTIME" +[[permission.scope.allow]] +path = "$RUNTIME/**" + +[[permission]] +identifier = "scope-runtime" +description = "This scope permits access to all files and list content of top level directories in the `$RUNTIME` folder." + +[[permission.scope.allow]] +path = "$RUNTIME" +[[permission.scope.allow]] +path = "$RUNTIME/*" + +[[permission]] +identifier = "scope-runtime-index" +description = "This scope permits to list all files and folders in the `$RUNTIME`folder." + +[[permission.scope.allow]] +path = "$RUNTIME" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-runtime-read-recursive" +description = "This allows full recursive read access to the complete `$RUNTIME` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-runtime-recursive" +] + +[[set]] +identifier = "allow-runtime-write-recursive" +description = "This allows full recursive write access to the complete `$RUNTIME` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-runtime-recursive" +] + +[[set]] +identifier = "allow-runtime-read" +description = "This allows non-recursive read access to the `$RUNTIME` folder." +permissions = [ + "read-all", + "scope-runtime" +] + +[[set]] +identifier = "allow-runtime-write" +description = "This allows non-recursive write access to the `$RUNTIME` folder." +permissions = [ + "write-all", + "scope-runtime" +] + +[[set]] +identifier = "allow-runtime-meta-recursive" +description = "This allows full recursive read access to metadata of the `$RUNTIME` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-runtime-recursive" +] + +[[set]] +identifier = "allow-runtime-meta" +description = "This allows non-recursive read access to metadata of the `$RUNTIME` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-runtime-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/temp.toml b/plugins/fs/permissions/autogenerated/base-directories/temp.toml new file mode 100644 index 0000000..c08e1da --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/temp.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-temp-recursive" +description = "This scope permits recursive access to the complete `$TEMP` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$TEMP" +[[permission.scope.allow]] +path = "$TEMP/**" + +[[permission]] +identifier = "scope-temp" +description = "This scope permits access to all files and list content of top level directories in the `$TEMP` folder." + +[[permission.scope.allow]] +path = "$TEMP" +[[permission.scope.allow]] +path = "$TEMP/*" + +[[permission]] +identifier = "scope-temp-index" +description = "This scope permits to list all files and folders in the `$TEMP`folder." + +[[permission.scope.allow]] +path = "$TEMP" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-temp-read-recursive" +description = "This allows full recursive read access to the complete `$TEMP` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-temp-recursive" +] + +[[set]] +identifier = "allow-temp-write-recursive" +description = "This allows full recursive write access to the complete `$TEMP` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-temp-recursive" +] + +[[set]] +identifier = "allow-temp-read" +description = "This allows non-recursive read access to the `$TEMP` folder." +permissions = [ + "read-all", + "scope-temp" +] + +[[set]] +identifier = "allow-temp-write" +description = "This allows non-recursive write access to the `$TEMP` folder." +permissions = [ + "write-all", + "scope-temp" +] + +[[set]] +identifier = "allow-temp-meta-recursive" +description = "This allows full recursive read access to metadata of the `$TEMP` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-temp-recursive" +] + +[[set]] +identifier = "allow-temp-meta" +description = "This allows non-recursive read access to metadata of the `$TEMP` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-temp-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/template.toml b/plugins/fs/permissions/autogenerated/base-directories/template.toml new file mode 100644 index 0000000..ce39f77 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/template.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-template-recursive" +description = "This scope permits recursive access to the complete `$TEMPLATE` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$TEMPLATE" +[[permission.scope.allow]] +path = "$TEMPLATE/**" + +[[permission]] +identifier = "scope-template" +description = "This scope permits access to all files and list content of top level directories in the `$TEMPLATE` folder." + +[[permission.scope.allow]] +path = "$TEMPLATE" +[[permission.scope.allow]] +path = "$TEMPLATE/*" + +[[permission]] +identifier = "scope-template-index" +description = "This scope permits to list all files and folders in the `$TEMPLATE`folder." + +[[permission.scope.allow]] +path = "$TEMPLATE" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-template-read-recursive" +description = "This allows full recursive read access to the complete `$TEMPLATE` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-template-recursive" +] + +[[set]] +identifier = "allow-template-write-recursive" +description = "This allows full recursive write access to the complete `$TEMPLATE` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-template-recursive" +] + +[[set]] +identifier = "allow-template-read" +description = "This allows non-recursive read access to the `$TEMPLATE` folder." +permissions = [ + "read-all", + "scope-template" +] + +[[set]] +identifier = "allow-template-write" +description = "This allows non-recursive write access to the `$TEMPLATE` folder." +permissions = [ + "write-all", + "scope-template" +] + +[[set]] +identifier = "allow-template-meta-recursive" +description = "This allows full recursive read access to metadata of the `$TEMPLATE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-template-recursive" +] + +[[set]] +identifier = "allow-template-meta" +description = "This allows non-recursive read access to metadata of the `$TEMPLATE` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-template-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/base-directories/video.toml b/plugins/fs/permissions/autogenerated/base-directories/video.toml new file mode 100644 index 0000000..df41abd --- /dev/null +++ b/plugins/fs/permissions/autogenerated/base-directories/video.toml @@ -0,0 +1,82 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +# Scopes Section +# This section contains scopes, which define file level access + +[[permission]] +identifier = "scope-video-recursive" +description = "This scope permits recursive access to the complete `$VIDEO` folder, including sub directories and files." + +[[permission.scope.allow]] +path = "$VIDEO" +[[permission.scope.allow]] +path = "$VIDEO/**" + +[[permission]] +identifier = "scope-video" +description = "This scope permits access to all files and list content of top level directories in the `$VIDEO` folder." + +[[permission.scope.allow]] +path = "$VIDEO" +[[permission.scope.allow]] +path = "$VIDEO/*" + +[[permission]] +identifier = "scope-video-index" +description = "This scope permits to list all files and folders in the `$VIDEO`folder." + +[[permission.scope.allow]] +path = "$VIDEO" + +# Sets Section +# This section combines the scope elements with enablement of commands + +[[set]] +identifier = "allow-video-read-recursive" +description = "This allows full recursive read access to the complete `$VIDEO` folder, files and subdirectories." +permissions = [ + "read-all", + "scope-video-recursive" +] + +[[set]] +identifier = "allow-video-write-recursive" +description = "This allows full recursive write access to the complete `$VIDEO` folder, files and subdirectories." +permissions = [ + "write-all", + "scope-video-recursive" +] + +[[set]] +identifier = "allow-video-read" +description = "This allows non-recursive read access to the `$VIDEO` folder." +permissions = [ + "read-all", + "scope-video" +] + +[[set]] +identifier = "allow-video-write" +description = "This allows non-recursive write access to the `$VIDEO` folder." +permissions = [ + "write-all", + "scope-video" +] + +[[set]] +identifier = "allow-video-meta-recursive" +description = "This allows full recursive read access to metadata of the `$VIDEO` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-video-recursive" +] + +[[set]] +identifier = "allow-video-meta" +description = "This allows non-recursive read access to metadata of the `$VIDEO` folder, including file listing and statistics." +permissions = [ + "read-meta", + "scope-video-index" +] \ No newline at end of file diff --git a/plugins/fs/permissions/autogenerated/commands/copy_file.toml b/plugins/fs/permissions/autogenerated/commands/copy_file.toml new file mode 100644 index 0000000..61bedf9 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/copy_file.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-copy-file" +description = "Enables the copy_file command without any pre-configured scope." +commands.allow = ["copy_file"] + +[[permission]] +identifier = "deny-copy-file" +description = "Denies the copy_file command without any pre-configured scope." +commands.deny = ["copy_file"] diff --git a/plugins/fs/permissions/autogenerated/commands/create.toml b/plugins/fs/permissions/autogenerated/commands/create.toml new file mode 100644 index 0000000..6646cc6 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/create.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create" +description = "Enables the create command without any pre-configured scope." +commands.allow = ["create"] + +[[permission]] +identifier = "deny-create" +description = "Denies the create command without any pre-configured scope." +commands.deny = ["create"] diff --git a/plugins/fs/permissions/autogenerated/commands/exists.toml b/plugins/fs/permissions/autogenerated/commands/exists.toml new file mode 100644 index 0000000..0eed148 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/exists.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-exists" +description = "Enables the exists command without any pre-configured scope." +commands.allow = ["exists"] + +[[permission]] +identifier = "deny-exists" +description = "Denies the exists command without any pre-configured scope." +commands.deny = ["exists"] diff --git a/plugins/fs/permissions/autogenerated/commands/fstat.toml b/plugins/fs/permissions/autogenerated/commands/fstat.toml new file mode 100644 index 0000000..30d2811 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/fstat.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fstat" +description = "Enables the fstat command without any pre-configured scope." +commands.allow = ["fstat"] + +[[permission]] +identifier = "deny-fstat" +description = "Denies the fstat command without any pre-configured scope." +commands.deny = ["fstat"] diff --git a/plugins/fs/permissions/autogenerated/commands/ftruncate.toml b/plugins/fs/permissions/autogenerated/commands/ftruncate.toml new file mode 100644 index 0000000..6b54ffe --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/ftruncate.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-ftruncate" +description = "Enables the ftruncate command without any pre-configured scope." +commands.allow = ["ftruncate"] + +[[permission]] +identifier = "deny-ftruncate" +description = "Denies the ftruncate command without any pre-configured scope." +commands.deny = ["ftruncate"] diff --git a/plugins/fs/permissions/autogenerated/commands/lstat.toml b/plugins/fs/permissions/autogenerated/commands/lstat.toml new file mode 100644 index 0000000..b224635 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/lstat.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-lstat" +description = "Enables the lstat command without any pre-configured scope." +commands.allow = ["lstat"] + +[[permission]] +identifier = "deny-lstat" +description = "Denies the lstat command without any pre-configured scope." +commands.deny = ["lstat"] diff --git a/plugins/fs/permissions/autogenerated/commands/mkdir.toml b/plugins/fs/permissions/autogenerated/commands/mkdir.toml new file mode 100644 index 0000000..58cdbbc --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/mkdir.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-mkdir" +description = "Enables the mkdir command without any pre-configured scope." +commands.allow = ["mkdir"] + +[[permission]] +identifier = "deny-mkdir" +description = "Denies the mkdir command without any pre-configured scope." +commands.deny = ["mkdir"] diff --git a/plugins/fs/permissions/autogenerated/commands/open.toml b/plugins/fs/permissions/autogenerated/commands/open.toml new file mode 100644 index 0000000..4ea6dff --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/open.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-open" +description = "Enables the open command without any pre-configured scope." +commands.allow = ["open"] + +[[permission]] +identifier = "deny-open" +description = "Denies the open command without any pre-configured scope." +commands.deny = ["open"] diff --git a/plugins/fs/permissions/autogenerated/commands/read.toml b/plugins/fs/permissions/autogenerated/commands/read.toml new file mode 100644 index 0000000..20fa10c --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read" +description = "Enables the read command without any pre-configured scope." +commands.allow = ["read"] + +[[permission]] +identifier = "deny-read" +description = "Denies the read command without any pre-configured scope." +commands.deny = ["read"] diff --git a/plugins/fs/permissions/autogenerated/commands/read_dir.toml b/plugins/fs/permissions/autogenerated/commands/read_dir.toml new file mode 100644 index 0000000..eef68eb --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read_dir.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-dir" +description = "Enables the read_dir command without any pre-configured scope." +commands.allow = ["read_dir"] + +[[permission]] +identifier = "deny-read-dir" +description = "Denies the read_dir command without any pre-configured scope." +commands.deny = ["read_dir"] diff --git a/plugins/fs/permissions/autogenerated/commands/read_file.toml b/plugins/fs/permissions/autogenerated/commands/read_file.toml new file mode 100644 index 0000000..b932b71 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read_file.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-file" +description = "Enables the read_file command without any pre-configured scope." +commands.allow = ["read_file"] + +[[permission]] +identifier = "deny-read-file" +description = "Denies the read_file command without any pre-configured scope." +commands.deny = ["read_file"] diff --git a/plugins/fs/permissions/autogenerated/commands/read_text_file.toml b/plugins/fs/permissions/autogenerated/commands/read_text_file.toml new file mode 100644 index 0000000..7a25115 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read_text_file.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-text-file" +description = "Enables the read_text_file command without any pre-configured scope." +commands.allow = ["read_text_file"] + +[[permission]] +identifier = "deny-read-text-file" +description = "Denies the read_text_file command without any pre-configured scope." +commands.deny = ["read_text_file"] diff --git a/plugins/fs/permissions/autogenerated/commands/read_text_file_lines.toml b/plugins/fs/permissions/autogenerated/commands/read_text_file_lines.toml new file mode 100644 index 0000000..84b4ebb --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read_text_file_lines.toml @@ -0,0 +1,22 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-text-file-lines" +description = "Enables the read_text_file_lines command without any pre-configured scope." + +[permission.commands] +allow = [ + "read_text_file_lines", + "read_text_file_lines_next", +] +deny = [] + +[[permission]] +identifier = "deny-read-text-file-lines" +description = "Denies the read_text_file_lines command without any pre-configured scope." + +[permission.commands] +allow = [] +deny = ["read_text_file_lines"] diff --git a/plugins/fs/permissions/autogenerated/commands/read_text_file_lines_next.toml b/plugins/fs/permissions/autogenerated/commands/read_text_file_lines_next.toml new file mode 100644 index 0000000..021ea37 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/read_text_file_lines_next.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-read-text-file-lines-next" +description = "Enables the read_text_file_lines_next command without any pre-configured scope." +commands.allow = ["read_text_file_lines_next"] + +[[permission]] +identifier = "deny-read-text-file-lines-next" +description = "Denies the read_text_file_lines_next command without any pre-configured scope." +commands.deny = ["read_text_file_lines_next"] diff --git a/plugins/fs/permissions/autogenerated/commands/remove.toml b/plugins/fs/permissions/autogenerated/commands/remove.toml new file mode 100644 index 0000000..9c9791e --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/remove.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove" +description = "Enables the remove command without any pre-configured scope." +commands.allow = ["remove"] + +[[permission]] +identifier = "deny-remove" +description = "Denies the remove command without any pre-configured scope." +commands.deny = ["remove"] diff --git a/plugins/fs/permissions/autogenerated/commands/rename.toml b/plugins/fs/permissions/autogenerated/commands/rename.toml new file mode 100644 index 0000000..91def18 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/rename.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-rename" +description = "Enables the rename command without any pre-configured scope." +commands.allow = ["rename"] + +[[permission]] +identifier = "deny-rename" +description = "Denies the rename command without any pre-configured scope." +commands.deny = ["rename"] diff --git a/plugins/fs/permissions/autogenerated/commands/seek.toml b/plugins/fs/permissions/autogenerated/commands/seek.toml new file mode 100644 index 0000000..cb21bdc --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/seek.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-seek" +description = "Enables the seek command without any pre-configured scope." +commands.allow = ["seek"] + +[[permission]] +identifier = "deny-seek" +description = "Denies the seek command without any pre-configured scope." +commands.deny = ["seek"] diff --git a/plugins/fs/permissions/autogenerated/commands/size.toml b/plugins/fs/permissions/autogenerated/commands/size.toml new file mode 100644 index 0000000..8a0ea55 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/size.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-size" +description = "Enables the size command without any pre-configured scope." +commands.allow = ["size"] + +[[permission]] +identifier = "deny-size" +description = "Denies the size command without any pre-configured scope." +commands.deny = ["size"] diff --git a/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml b/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml new file mode 100644 index 0000000..d14c502 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/start_accessing_security_scoped_resource.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-start-accessing-security-scoped-resource" +description = "Enables the start_accessing_security_scoped_resource command without any pre-configured scope." +commands.allow = ["start_accessing_security_scoped_resource"] + +[[permission]] +identifier = "deny-start-accessing-security-scoped-resource" +description = "Denies the start_accessing_security_scoped_resource command without any pre-configured scope." +commands.deny = ["start_accessing_security_scoped_resource"] diff --git a/plugins/fs/permissions/autogenerated/commands/stat.toml b/plugins/fs/permissions/autogenerated/commands/stat.toml new file mode 100644 index 0000000..56f751b --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/stat.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stat" +description = "Enables the stat command without any pre-configured scope." +commands.allow = ["stat"] + +[[permission]] +identifier = "deny-stat" +description = "Denies the stat command without any pre-configured scope." +commands.deny = ["stat"] diff --git a/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml b/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml new file mode 100644 index 0000000..fae3f19 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/stop_accessing_security_scoped_resource.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop-accessing-security-scoped-resource" +description = "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope." +commands.allow = ["stop_accessing_security_scoped_resource"] + +[[permission]] +identifier = "deny-stop-accessing-security-scoped-resource" +description = "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope." +commands.deny = ["stop_accessing_security_scoped_resource"] diff --git a/plugins/fs/permissions/autogenerated/commands/truncate.toml b/plugins/fs/permissions/autogenerated/commands/truncate.toml new file mode 100644 index 0000000..62fbb14 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/truncate.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-truncate" +description = "Enables the truncate command without any pre-configured scope." +commands.allow = ["truncate"] + +[[permission]] +identifier = "deny-truncate" +description = "Denies the truncate command without any pre-configured scope." +commands.deny = ["truncate"] diff --git a/plugins/fs/permissions/autogenerated/commands/unwatch.toml b/plugins/fs/permissions/autogenerated/commands/unwatch.toml new file mode 100644 index 0000000..3259e9a --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/unwatch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unwatch" +description = "Enables the unwatch command without any pre-configured scope." +commands.allow = ["unwatch"] + +[[permission]] +identifier = "deny-unwatch" +description = "Denies the unwatch command without any pre-configured scope." +commands.deny = ["unwatch"] diff --git a/plugins/fs/permissions/autogenerated/commands/watch.toml b/plugins/fs/permissions/autogenerated/commands/watch.toml new file mode 100644 index 0000000..8dd1b57 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/watch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-watch" +description = "Enables the watch command without any pre-configured scope." +commands.allow = ["watch"] + +[[permission]] +identifier = "deny-watch" +description = "Denies the watch command without any pre-configured scope." +commands.deny = ["watch"] diff --git a/plugins/fs/permissions/autogenerated/commands/write.toml b/plugins/fs/permissions/autogenerated/commands/write.toml new file mode 100644 index 0000000..73d1d38 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/write.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-write" +description = "Enables the write command without any pre-configured scope." +commands.allow = ["write"] + +[[permission]] +identifier = "deny-write" +description = "Denies the write command without any pre-configured scope." +commands.deny = ["write"] diff --git a/plugins/fs/permissions/autogenerated/commands/write_file.toml b/plugins/fs/permissions/autogenerated/commands/write_file.toml new file mode 100644 index 0000000..ea7d513 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/write_file.toml @@ -0,0 +1,23 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-write-file" +description = "Enables the write_file command without any pre-configured scope." + +[permission.commands] +allow = [ + "write_file", + "open", + "write", +] +deny = [] + +[[permission]] +identifier = "deny-write-file" +description = "Denies the write_file command without any pre-configured scope." + +[permission.commands] +allow = [] +deny = ["write_file"] diff --git a/plugins/fs/permissions/autogenerated/commands/write_text_file.toml b/plugins/fs/permissions/autogenerated/commands/write_text_file.toml new file mode 100644 index 0000000..6b497a7 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/commands/write_text_file.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-write-text-file" +description = "Enables the write_text_file command without any pre-configured scope." +commands.allow = ["write_text_file"] + +[[permission]] +identifier = "deny-write-text-file" +description = "Denies the write_text_file command without any pre-configured scope." +commands.deny = ["write_text_file"] diff --git a/plugins/fs/permissions/autogenerated/reference.md b/plugins/fs/permissions/autogenerated/reference.md new file mode 100644 index 0000000..49589f0 --- /dev/null +++ b/plugins/fs/permissions/autogenerated/reference.md @@ -0,0 +1,3866 @@ +## Default Permission + +This set of permissions describes the what kind of +file system access the `fs` plugin has enabled or denied by default. + +#### Granted Permissions + +This default permission set enables read access to the +application specific directories (AppConfig, AppData, AppLocalData, AppCache, +AppLog) and all files and sub directories created in it. +The location of these directories depends on the operating system, +where the application is run. + +In general these directories need to be manually created +by the application at runtime, before accessing files or folders +in it is possible. + +Therefore, it is also allowed to create all of these folders via +the `mkdir` command. + +#### Denied Permissions + +This default permission set prevents access to critical components +of the Tauri application by default. + +- On Windows the access to webview data folder `$APPLOCALDATA/EBWebView` is denied. +- On Linux the access to webview data paths inside `$APPLOCALDATA` are denied. + +#### This default permission set includes the following: + +- `create-app-specific-dirs` +- `read-app-specific-dirs-recursive` +- `deny-default` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`fs:allow-app-read-recursive` + + + +This allows full recursive read access to the complete application folders, files and subdirectories. + +
+ +`fs:allow-app-write-recursive` + + + +This allows full recursive write access to the complete application folders, files and subdirectories. + +
+ +`fs:allow-app-read` + + + +This allows non-recursive read access to the application folders. + +
+ +`fs:allow-app-write` + + + +This allows non-recursive write access to the application folders. + +
+ +`fs:allow-app-meta-recursive` + + + +This allows full recursive read access to metadata of the application folders, including file listing and statistics. + +
+ +`fs:allow-app-meta` + + + +This allows non-recursive read access to metadata of the application folders, including file listing and statistics. + +
+ +`fs:scope-app-recursive` + + + +This scope permits recursive access to the complete application folders, including sub directories and files. + +
+ +`fs:scope-app` + + + +This scope permits access to all files and list content of top level directories in the application folders. + +
+ +`fs:scope-app-index` + + + +This scope permits to list all files and folders in the application directories. + +
+ +`fs:allow-appcache-read-recursive` + + + +This allows full recursive read access to the complete `$APPCACHE` folder, files and subdirectories. + +
+ +`fs:allow-appcache-write-recursive` + + + +This allows full recursive write access to the complete `$APPCACHE` folder, files and subdirectories. + +
+ +`fs:allow-appcache-read` + + + +This allows non-recursive read access to the `$APPCACHE` folder. + +
+ +`fs:allow-appcache-write` + + + +This allows non-recursive write access to the `$APPCACHE` folder. + +
+ +`fs:allow-appcache-meta-recursive` + + + +This allows full recursive read access to metadata of the `$APPCACHE` folder, including file listing and statistics. + +
+ +`fs:allow-appcache-meta` + + + +This allows non-recursive read access to metadata of the `$APPCACHE` folder, including file listing and statistics. + +
+ +`fs:scope-appcache-recursive` + + + +This scope permits recursive access to the complete `$APPCACHE` folder, including sub directories and files. + +
+ +`fs:scope-appcache` + + + +This scope permits access to all files and list content of top level directories in the `$APPCACHE` folder. + +
+ +`fs:scope-appcache-index` + + + +This scope permits to list all files and folders in the `$APPCACHE`folder. + +
+ +`fs:allow-appconfig-read-recursive` + + + +This allows full recursive read access to the complete `$APPCONFIG` folder, files and subdirectories. + +
+ +`fs:allow-appconfig-write-recursive` + + + +This allows full recursive write access to the complete `$APPCONFIG` folder, files and subdirectories. + +
+ +`fs:allow-appconfig-read` + + + +This allows non-recursive read access to the `$APPCONFIG` folder. + +
+ +`fs:allow-appconfig-write` + + + +This allows non-recursive write access to the `$APPCONFIG` folder. + +
+ +`fs:allow-appconfig-meta-recursive` + + + +This allows full recursive read access to metadata of the `$APPCONFIG` folder, including file listing and statistics. + +
+ +`fs:allow-appconfig-meta` + + + +This allows non-recursive read access to metadata of the `$APPCONFIG` folder, including file listing and statistics. + +
+ +`fs:scope-appconfig-recursive` + + + +This scope permits recursive access to the complete `$APPCONFIG` folder, including sub directories and files. + +
+ +`fs:scope-appconfig` + + + +This scope permits access to all files and list content of top level directories in the `$APPCONFIG` folder. + +
+ +`fs:scope-appconfig-index` + + + +This scope permits to list all files and folders in the `$APPCONFIG`folder. + +
+ +`fs:allow-appdata-read-recursive` + + + +This allows full recursive read access to the complete `$APPDATA` folder, files and subdirectories. + +
+ +`fs:allow-appdata-write-recursive` + + + +This allows full recursive write access to the complete `$APPDATA` folder, files and subdirectories. + +
+ +`fs:allow-appdata-read` + + + +This allows non-recursive read access to the `$APPDATA` folder. + +
+ +`fs:allow-appdata-write` + + + +This allows non-recursive write access to the `$APPDATA` folder. + +
+ +`fs:allow-appdata-meta-recursive` + + + +This allows full recursive read access to metadata of the `$APPDATA` folder, including file listing and statistics. + +
+ +`fs:allow-appdata-meta` + + + +This allows non-recursive read access to metadata of the `$APPDATA` folder, including file listing and statistics. + +
+ +`fs:scope-appdata-recursive` + + + +This scope permits recursive access to the complete `$APPDATA` folder, including sub directories and files. + +
+ +`fs:scope-appdata` + + + +This scope permits access to all files and list content of top level directories in the `$APPDATA` folder. + +
+ +`fs:scope-appdata-index` + + + +This scope permits to list all files and folders in the `$APPDATA`folder. + +
+ +`fs:allow-applocaldata-read-recursive` + + + +This allows full recursive read access to the complete `$APPLOCALDATA` folder, files and subdirectories. + +
+ +`fs:allow-applocaldata-write-recursive` + + + +This allows full recursive write access to the complete `$APPLOCALDATA` folder, files and subdirectories. + +
+ +`fs:allow-applocaldata-read` + + + +This allows non-recursive read access to the `$APPLOCALDATA` folder. + +
+ +`fs:allow-applocaldata-write` + + + +This allows non-recursive write access to the `$APPLOCALDATA` folder. + +
+ +`fs:allow-applocaldata-meta-recursive` + + + +This allows full recursive read access to metadata of the `$APPLOCALDATA` folder, including file listing and statistics. + +
+ +`fs:allow-applocaldata-meta` + + + +This allows non-recursive read access to metadata of the `$APPLOCALDATA` folder, including file listing and statistics. + +
+ +`fs:scope-applocaldata-recursive` + + + +This scope permits recursive access to the complete `$APPLOCALDATA` folder, including sub directories and files. + +
+ +`fs:scope-applocaldata` + + + +This scope permits access to all files and list content of top level directories in the `$APPLOCALDATA` folder. + +
+ +`fs:scope-applocaldata-index` + + + +This scope permits to list all files and folders in the `$APPLOCALDATA`folder. + +
+ +`fs:allow-applog-read-recursive` + + + +This allows full recursive read access to the complete `$APPLOG` folder, files and subdirectories. + +
+ +`fs:allow-applog-write-recursive` + + + +This allows full recursive write access to the complete `$APPLOG` folder, files and subdirectories. + +
+ +`fs:allow-applog-read` + + + +This allows non-recursive read access to the `$APPLOG` folder. + +
+ +`fs:allow-applog-write` + + + +This allows non-recursive write access to the `$APPLOG` folder. + +
+ +`fs:allow-applog-meta-recursive` + + + +This allows full recursive read access to metadata of the `$APPLOG` folder, including file listing and statistics. + +
+ +`fs:allow-applog-meta` + + + +This allows non-recursive read access to metadata of the `$APPLOG` folder, including file listing and statistics. + +
+ +`fs:scope-applog-recursive` + + + +This scope permits recursive access to the complete `$APPLOG` folder, including sub directories and files. + +
+ +`fs:scope-applog` + + + +This scope permits access to all files and list content of top level directories in the `$APPLOG` folder. + +
+ +`fs:scope-applog-index` + + + +This scope permits to list all files and folders in the `$APPLOG`folder. + +
+ +`fs:allow-audio-read-recursive` + + + +This allows full recursive read access to the complete `$AUDIO` folder, files and subdirectories. + +
+ +`fs:allow-audio-write-recursive` + + + +This allows full recursive write access to the complete `$AUDIO` folder, files and subdirectories. + +
+ +`fs:allow-audio-read` + + + +This allows non-recursive read access to the `$AUDIO` folder. + +
+ +`fs:allow-audio-write` + + + +This allows non-recursive write access to the `$AUDIO` folder. + +
+ +`fs:allow-audio-meta-recursive` + + + +This allows full recursive read access to metadata of the `$AUDIO` folder, including file listing and statistics. + +
+ +`fs:allow-audio-meta` + + + +This allows non-recursive read access to metadata of the `$AUDIO` folder, including file listing and statistics. + +
+ +`fs:scope-audio-recursive` + + + +This scope permits recursive access to the complete `$AUDIO` folder, including sub directories and files. + +
+ +`fs:scope-audio` + + + +This scope permits access to all files and list content of top level directories in the `$AUDIO` folder. + +
+ +`fs:scope-audio-index` + + + +This scope permits to list all files and folders in the `$AUDIO`folder. + +
+ +`fs:allow-cache-read-recursive` + + + +This allows full recursive read access to the complete `$CACHE` folder, files and subdirectories. + +
+ +`fs:allow-cache-write-recursive` + + + +This allows full recursive write access to the complete `$CACHE` folder, files and subdirectories. + +
+ +`fs:allow-cache-read` + + + +This allows non-recursive read access to the `$CACHE` folder. + +
+ +`fs:allow-cache-write` + + + +This allows non-recursive write access to the `$CACHE` folder. + +
+ +`fs:allow-cache-meta-recursive` + + + +This allows full recursive read access to metadata of the `$CACHE` folder, including file listing and statistics. + +
+ +`fs:allow-cache-meta` + + + +This allows non-recursive read access to metadata of the `$CACHE` folder, including file listing and statistics. + +
+ +`fs:scope-cache-recursive` + + + +This scope permits recursive access to the complete `$CACHE` folder, including sub directories and files. + +
+ +`fs:scope-cache` + + + +This scope permits access to all files and list content of top level directories in the `$CACHE` folder. + +
+ +`fs:scope-cache-index` + + + +This scope permits to list all files and folders in the `$CACHE`folder. + +
+ +`fs:allow-config-read-recursive` + + + +This allows full recursive read access to the complete `$CONFIG` folder, files and subdirectories. + +
+ +`fs:allow-config-write-recursive` + + + +This allows full recursive write access to the complete `$CONFIG` folder, files and subdirectories. + +
+ +`fs:allow-config-read` + + + +This allows non-recursive read access to the `$CONFIG` folder. + +
+ +`fs:allow-config-write` + + + +This allows non-recursive write access to the `$CONFIG` folder. + +
+ +`fs:allow-config-meta-recursive` + + + +This allows full recursive read access to metadata of the `$CONFIG` folder, including file listing and statistics. + +
+ +`fs:allow-config-meta` + + + +This allows non-recursive read access to metadata of the `$CONFIG` folder, including file listing and statistics. + +
+ +`fs:scope-config-recursive` + + + +This scope permits recursive access to the complete `$CONFIG` folder, including sub directories and files. + +
+ +`fs:scope-config` + + + +This scope permits access to all files and list content of top level directories in the `$CONFIG` folder. + +
+ +`fs:scope-config-index` + + + +This scope permits to list all files and folders in the `$CONFIG`folder. + +
+ +`fs:allow-data-read-recursive` + + + +This allows full recursive read access to the complete `$DATA` folder, files and subdirectories. + +
+ +`fs:allow-data-write-recursive` + + + +This allows full recursive write access to the complete `$DATA` folder, files and subdirectories. + +
+ +`fs:allow-data-read` + + + +This allows non-recursive read access to the `$DATA` folder. + +
+ +`fs:allow-data-write` + + + +This allows non-recursive write access to the `$DATA` folder. + +
+ +`fs:allow-data-meta-recursive` + + + +This allows full recursive read access to metadata of the `$DATA` folder, including file listing and statistics. + +
+ +`fs:allow-data-meta` + + + +This allows non-recursive read access to metadata of the `$DATA` folder, including file listing and statistics. + +
+ +`fs:scope-data-recursive` + + + +This scope permits recursive access to the complete `$DATA` folder, including sub directories and files. + +
+ +`fs:scope-data` + + + +This scope permits access to all files and list content of top level directories in the `$DATA` folder. + +
+ +`fs:scope-data-index` + + + +This scope permits to list all files and folders in the `$DATA`folder. + +
+ +`fs:allow-desktop-read-recursive` + + + +This allows full recursive read access to the complete `$DESKTOP` folder, files and subdirectories. + +
+ +`fs:allow-desktop-write-recursive` + + + +This allows full recursive write access to the complete `$DESKTOP` folder, files and subdirectories. + +
+ +`fs:allow-desktop-read` + + + +This allows non-recursive read access to the `$DESKTOP` folder. + +
+ +`fs:allow-desktop-write` + + + +This allows non-recursive write access to the `$DESKTOP` folder. + +
+ +`fs:allow-desktop-meta-recursive` + + + +This allows full recursive read access to metadata of the `$DESKTOP` folder, including file listing and statistics. + +
+ +`fs:allow-desktop-meta` + + + +This allows non-recursive read access to metadata of the `$DESKTOP` folder, including file listing and statistics. + +
+ +`fs:scope-desktop-recursive` + + + +This scope permits recursive access to the complete `$DESKTOP` folder, including sub directories and files. + +
+ +`fs:scope-desktop` + + + +This scope permits access to all files and list content of top level directories in the `$DESKTOP` folder. + +
+ +`fs:scope-desktop-index` + + + +This scope permits to list all files and folders in the `$DESKTOP`folder. + +
+ +`fs:allow-document-read-recursive` + + + +This allows full recursive read access to the complete `$DOCUMENT` folder, files and subdirectories. + +
+ +`fs:allow-document-write-recursive` + + + +This allows full recursive write access to the complete `$DOCUMENT` folder, files and subdirectories. + +
+ +`fs:allow-document-read` + + + +This allows non-recursive read access to the `$DOCUMENT` folder. + +
+ +`fs:allow-document-write` + + + +This allows non-recursive write access to the `$DOCUMENT` folder. + +
+ +`fs:allow-document-meta-recursive` + + + +This allows full recursive read access to metadata of the `$DOCUMENT` folder, including file listing and statistics. + +
+ +`fs:allow-document-meta` + + + +This allows non-recursive read access to metadata of the `$DOCUMENT` folder, including file listing and statistics. + +
+ +`fs:scope-document-recursive` + + + +This scope permits recursive access to the complete `$DOCUMENT` folder, including sub directories and files. + +
+ +`fs:scope-document` + + + +This scope permits access to all files and list content of top level directories in the `$DOCUMENT` folder. + +
+ +`fs:scope-document-index` + + + +This scope permits to list all files and folders in the `$DOCUMENT`folder. + +
+ +`fs:allow-download-read-recursive` + + + +This allows full recursive read access to the complete `$DOWNLOAD` folder, files and subdirectories. + +
+ +`fs:allow-download-write-recursive` + + + +This allows full recursive write access to the complete `$DOWNLOAD` folder, files and subdirectories. + +
+ +`fs:allow-download-read` + + + +This allows non-recursive read access to the `$DOWNLOAD` folder. + +
+ +`fs:allow-download-write` + + + +This allows non-recursive write access to the `$DOWNLOAD` folder. + +
+ +`fs:allow-download-meta-recursive` + + + +This allows full recursive read access to metadata of the `$DOWNLOAD` folder, including file listing and statistics. + +
+ +`fs:allow-download-meta` + + + +This allows non-recursive read access to metadata of the `$DOWNLOAD` folder, including file listing and statistics. + +
+ +`fs:scope-download-recursive` + + + +This scope permits recursive access to the complete `$DOWNLOAD` folder, including sub directories and files. + +
+ +`fs:scope-download` + + + +This scope permits access to all files and list content of top level directories in the `$DOWNLOAD` folder. + +
+ +`fs:scope-download-index` + + + +This scope permits to list all files and folders in the `$DOWNLOAD`folder. + +
+ +`fs:allow-exe-read-recursive` + + + +This allows full recursive read access to the complete `$EXE` folder, files and subdirectories. + +
+ +`fs:allow-exe-write-recursive` + + + +This allows full recursive write access to the complete `$EXE` folder, files and subdirectories. + +
+ +`fs:allow-exe-read` + + + +This allows non-recursive read access to the `$EXE` folder. + +
+ +`fs:allow-exe-write` + + + +This allows non-recursive write access to the `$EXE` folder. + +
+ +`fs:allow-exe-meta-recursive` + + + +This allows full recursive read access to metadata of the `$EXE` folder, including file listing and statistics. + +
+ +`fs:allow-exe-meta` + + + +This allows non-recursive read access to metadata of the `$EXE` folder, including file listing and statistics. + +
+ +`fs:scope-exe-recursive` + + + +This scope permits recursive access to the complete `$EXE` folder, including sub directories and files. + +
+ +`fs:scope-exe` + + + +This scope permits access to all files and list content of top level directories in the `$EXE` folder. + +
+ +`fs:scope-exe-index` + + + +This scope permits to list all files and folders in the `$EXE`folder. + +
+ +`fs:allow-font-read-recursive` + + + +This allows full recursive read access to the complete `$FONT` folder, files and subdirectories. + +
+ +`fs:allow-font-write-recursive` + + + +This allows full recursive write access to the complete `$FONT` folder, files and subdirectories. + +
+ +`fs:allow-font-read` + + + +This allows non-recursive read access to the `$FONT` folder. + +
+ +`fs:allow-font-write` + + + +This allows non-recursive write access to the `$FONT` folder. + +
+ +`fs:allow-font-meta-recursive` + + + +This allows full recursive read access to metadata of the `$FONT` folder, including file listing and statistics. + +
+ +`fs:allow-font-meta` + + + +This allows non-recursive read access to metadata of the `$FONT` folder, including file listing and statistics. + +
+ +`fs:scope-font-recursive` + + + +This scope permits recursive access to the complete `$FONT` folder, including sub directories and files. + +
+ +`fs:scope-font` + + + +This scope permits access to all files and list content of top level directories in the `$FONT` folder. + +
+ +`fs:scope-font-index` + + + +This scope permits to list all files and folders in the `$FONT`folder. + +
+ +`fs:allow-home-read-recursive` + + + +This allows full recursive read access to the complete `$HOME` folder, files and subdirectories. + +
+ +`fs:allow-home-write-recursive` + + + +This allows full recursive write access to the complete `$HOME` folder, files and subdirectories. + +
+ +`fs:allow-home-read` + + + +This allows non-recursive read access to the `$HOME` folder. + +
+ +`fs:allow-home-write` + + + +This allows non-recursive write access to the `$HOME` folder. + +
+ +`fs:allow-home-meta-recursive` + + + +This allows full recursive read access to metadata of the `$HOME` folder, including file listing and statistics. + +
+ +`fs:allow-home-meta` + + + +This allows non-recursive read access to metadata of the `$HOME` folder, including file listing and statistics. + +
+ +`fs:scope-home-recursive` + + + +This scope permits recursive access to the complete `$HOME` folder, including sub directories and files. + +
+ +`fs:scope-home` + + + +This scope permits access to all files and list content of top level directories in the `$HOME` folder. + +
+ +`fs:scope-home-index` + + + +This scope permits to list all files and folders in the `$HOME`folder. + +
+ +`fs:allow-localdata-read-recursive` + + + +This allows full recursive read access to the complete `$LOCALDATA` folder, files and subdirectories. + +
+ +`fs:allow-localdata-write-recursive` + + + +This allows full recursive write access to the complete `$LOCALDATA` folder, files and subdirectories. + +
+ +`fs:allow-localdata-read` + + + +This allows non-recursive read access to the `$LOCALDATA` folder. + +
+ +`fs:allow-localdata-write` + + + +This allows non-recursive write access to the `$LOCALDATA` folder. + +
+ +`fs:allow-localdata-meta-recursive` + + + +This allows full recursive read access to metadata of the `$LOCALDATA` folder, including file listing and statistics. + +
+ +`fs:allow-localdata-meta` + + + +This allows non-recursive read access to metadata of the `$LOCALDATA` folder, including file listing and statistics. + +
+ +`fs:scope-localdata-recursive` + + + +This scope permits recursive access to the complete `$LOCALDATA` folder, including sub directories and files. + +
+ +`fs:scope-localdata` + + + +This scope permits access to all files and list content of top level directories in the `$LOCALDATA` folder. + +
+ +`fs:scope-localdata-index` + + + +This scope permits to list all files and folders in the `$LOCALDATA`folder. + +
+ +`fs:allow-log-read-recursive` + + + +This allows full recursive read access to the complete `$LOG` folder, files and subdirectories. + +
+ +`fs:allow-log-write-recursive` + + + +This allows full recursive write access to the complete `$LOG` folder, files and subdirectories. + +
+ +`fs:allow-log-read` + + + +This allows non-recursive read access to the `$LOG` folder. + +
+ +`fs:allow-log-write` + + + +This allows non-recursive write access to the `$LOG` folder. + +
+ +`fs:allow-log-meta-recursive` + + + +This allows full recursive read access to metadata of the `$LOG` folder, including file listing and statistics. + +
+ +`fs:allow-log-meta` + + + +This allows non-recursive read access to metadata of the `$LOG` folder, including file listing and statistics. + +
+ +`fs:scope-log-recursive` + + + +This scope permits recursive access to the complete `$LOG` folder, including sub directories and files. + +
+ +`fs:scope-log` + + + +This scope permits access to all files and list content of top level directories in the `$LOG` folder. + +
+ +`fs:scope-log-index` + + + +This scope permits to list all files and folders in the `$LOG`folder. + +
+ +`fs:allow-picture-read-recursive` + + + +This allows full recursive read access to the complete `$PICTURE` folder, files and subdirectories. + +
+ +`fs:allow-picture-write-recursive` + + + +This allows full recursive write access to the complete `$PICTURE` folder, files and subdirectories. + +
+ +`fs:allow-picture-read` + + + +This allows non-recursive read access to the `$PICTURE` folder. + +
+ +`fs:allow-picture-write` + + + +This allows non-recursive write access to the `$PICTURE` folder. + +
+ +`fs:allow-picture-meta-recursive` + + + +This allows full recursive read access to metadata of the `$PICTURE` folder, including file listing and statistics. + +
+ +`fs:allow-picture-meta` + + + +This allows non-recursive read access to metadata of the `$PICTURE` folder, including file listing and statistics. + +
+ +`fs:scope-picture-recursive` + + + +This scope permits recursive access to the complete `$PICTURE` folder, including sub directories and files. + +
+ +`fs:scope-picture` + + + +This scope permits access to all files and list content of top level directories in the `$PICTURE` folder. + +
+ +`fs:scope-picture-index` + + + +This scope permits to list all files and folders in the `$PICTURE`folder. + +
+ +`fs:allow-public-read-recursive` + + + +This allows full recursive read access to the complete `$PUBLIC` folder, files and subdirectories. + +
+ +`fs:allow-public-write-recursive` + + + +This allows full recursive write access to the complete `$PUBLIC` folder, files and subdirectories. + +
+ +`fs:allow-public-read` + + + +This allows non-recursive read access to the `$PUBLIC` folder. + +
+ +`fs:allow-public-write` + + + +This allows non-recursive write access to the `$PUBLIC` folder. + +
+ +`fs:allow-public-meta-recursive` + + + +This allows full recursive read access to metadata of the `$PUBLIC` folder, including file listing and statistics. + +
+ +`fs:allow-public-meta` + + + +This allows non-recursive read access to metadata of the `$PUBLIC` folder, including file listing and statistics. + +
+ +`fs:scope-public-recursive` + + + +This scope permits recursive access to the complete `$PUBLIC` folder, including sub directories and files. + +
+ +`fs:scope-public` + + + +This scope permits access to all files and list content of top level directories in the `$PUBLIC` folder. + +
+ +`fs:scope-public-index` + + + +This scope permits to list all files and folders in the `$PUBLIC`folder. + +
+ +`fs:allow-resource-read-recursive` + + + +This allows full recursive read access to the complete `$RESOURCE` folder, files and subdirectories. + +
+ +`fs:allow-resource-write-recursive` + + + +This allows full recursive write access to the complete `$RESOURCE` folder, files and subdirectories. + +
+ +`fs:allow-resource-read` + + + +This allows non-recursive read access to the `$RESOURCE` folder. + +
+ +`fs:allow-resource-write` + + + +This allows non-recursive write access to the `$RESOURCE` folder. + +
+ +`fs:allow-resource-meta-recursive` + + + +This allows full recursive read access to metadata of the `$RESOURCE` folder, including file listing and statistics. + +
+ +`fs:allow-resource-meta` + + + +This allows non-recursive read access to metadata of the `$RESOURCE` folder, including file listing and statistics. + +
+ +`fs:scope-resource-recursive` + + + +This scope permits recursive access to the complete `$RESOURCE` folder, including sub directories and files. + +
+ +`fs:scope-resource` + + + +This scope permits access to all files and list content of top level directories in the `$RESOURCE` folder. + +
+ +`fs:scope-resource-index` + + + +This scope permits to list all files and folders in the `$RESOURCE`folder. + +
+ +`fs:allow-runtime-read-recursive` + + + +This allows full recursive read access to the complete `$RUNTIME` folder, files and subdirectories. + +
+ +`fs:allow-runtime-write-recursive` + + + +This allows full recursive write access to the complete `$RUNTIME` folder, files and subdirectories. + +
+ +`fs:allow-runtime-read` + + + +This allows non-recursive read access to the `$RUNTIME` folder. + +
+ +`fs:allow-runtime-write` + + + +This allows non-recursive write access to the `$RUNTIME` folder. + +
+ +`fs:allow-runtime-meta-recursive` + + + +This allows full recursive read access to metadata of the `$RUNTIME` folder, including file listing and statistics. + +
+ +`fs:allow-runtime-meta` + + + +This allows non-recursive read access to metadata of the `$RUNTIME` folder, including file listing and statistics. + +
+ +`fs:scope-runtime-recursive` + + + +This scope permits recursive access to the complete `$RUNTIME` folder, including sub directories and files. + +
+ +`fs:scope-runtime` + + + +This scope permits access to all files and list content of top level directories in the `$RUNTIME` folder. + +
+ +`fs:scope-runtime-index` + + + +This scope permits to list all files and folders in the `$RUNTIME`folder. + +
+ +`fs:allow-temp-read-recursive` + + + +This allows full recursive read access to the complete `$TEMP` folder, files and subdirectories. + +
+ +`fs:allow-temp-write-recursive` + + + +This allows full recursive write access to the complete `$TEMP` folder, files and subdirectories. + +
+ +`fs:allow-temp-read` + + + +This allows non-recursive read access to the `$TEMP` folder. + +
+ +`fs:allow-temp-write` + + + +This allows non-recursive write access to the `$TEMP` folder. + +
+ +`fs:allow-temp-meta-recursive` + + + +This allows full recursive read access to metadata of the `$TEMP` folder, including file listing and statistics. + +
+ +`fs:allow-temp-meta` + + + +This allows non-recursive read access to metadata of the `$TEMP` folder, including file listing and statistics. + +
+ +`fs:scope-temp-recursive` + + + +This scope permits recursive access to the complete `$TEMP` folder, including sub directories and files. + +
+ +`fs:scope-temp` + + + +This scope permits access to all files and list content of top level directories in the `$TEMP` folder. + +
+ +`fs:scope-temp-index` + + + +This scope permits to list all files and folders in the `$TEMP`folder. + +
+ +`fs:allow-template-read-recursive` + + + +This allows full recursive read access to the complete `$TEMPLATE` folder, files and subdirectories. + +
+ +`fs:allow-template-write-recursive` + + + +This allows full recursive write access to the complete `$TEMPLATE` folder, files and subdirectories. + +
+ +`fs:allow-template-read` + + + +This allows non-recursive read access to the `$TEMPLATE` folder. + +
+ +`fs:allow-template-write` + + + +This allows non-recursive write access to the `$TEMPLATE` folder. + +
+ +`fs:allow-template-meta-recursive` + + + +This allows full recursive read access to metadata of the `$TEMPLATE` folder, including file listing and statistics. + +
+ +`fs:allow-template-meta` + + + +This allows non-recursive read access to metadata of the `$TEMPLATE` folder, including file listing and statistics. + +
+ +`fs:scope-template-recursive` + + + +This scope permits recursive access to the complete `$TEMPLATE` folder, including sub directories and files. + +
+ +`fs:scope-template` + + + +This scope permits access to all files and list content of top level directories in the `$TEMPLATE` folder. + +
+ +`fs:scope-template-index` + + + +This scope permits to list all files and folders in the `$TEMPLATE`folder. + +
+ +`fs:allow-video-read-recursive` + + + +This allows full recursive read access to the complete `$VIDEO` folder, files and subdirectories. + +
+ +`fs:allow-video-write-recursive` + + + +This allows full recursive write access to the complete `$VIDEO` folder, files and subdirectories. + +
+ +`fs:allow-video-read` + + + +This allows non-recursive read access to the `$VIDEO` folder. + +
+ +`fs:allow-video-write` + + + +This allows non-recursive write access to the `$VIDEO` folder. + +
+ +`fs:allow-video-meta-recursive` + + + +This allows full recursive read access to metadata of the `$VIDEO` folder, including file listing and statistics. + +
+ +`fs:allow-video-meta` + + + +This allows non-recursive read access to metadata of the `$VIDEO` folder, including file listing and statistics. + +
+ +`fs:scope-video-recursive` + + + +This scope permits recursive access to the complete `$VIDEO` folder, including sub directories and files. + +
+ +`fs:scope-video` + + + +This scope permits access to all files and list content of top level directories in the `$VIDEO` folder. + +
+ +`fs:scope-video-index` + + + +This scope permits to list all files and folders in the `$VIDEO`folder. + +
+ +`fs:allow-copy-file` + + + +Enables the copy_file command without any pre-configured scope. + +
+ +`fs:deny-copy-file` + + + +Denies the copy_file command without any pre-configured scope. + +
+ +`fs:allow-create` + + + +Enables the create command without any pre-configured scope. + +
+ +`fs:deny-create` + + + +Denies the create command without any pre-configured scope. + +
+ +`fs:allow-exists` + + + +Enables the exists command without any pre-configured scope. + +
+ +`fs:deny-exists` + + + +Denies the exists command without any pre-configured scope. + +
+ +`fs:allow-fstat` + + + +Enables the fstat command without any pre-configured scope. + +
+ +`fs:deny-fstat` + + + +Denies the fstat command without any pre-configured scope. + +
+ +`fs:allow-ftruncate` + + + +Enables the ftruncate command without any pre-configured scope. + +
+ +`fs:deny-ftruncate` + + + +Denies the ftruncate command without any pre-configured scope. + +
+ +`fs:allow-lstat` + + + +Enables the lstat command without any pre-configured scope. + +
+ +`fs:deny-lstat` + + + +Denies the lstat command without any pre-configured scope. + +
+ +`fs:allow-mkdir` + + + +Enables the mkdir command without any pre-configured scope. + +
+ +`fs:deny-mkdir` + + + +Denies the mkdir command without any pre-configured scope. + +
+ +`fs:allow-open` + + + +Enables the open command without any pre-configured scope. + +
+ +`fs:deny-open` + + + +Denies the open command without any pre-configured scope. + +
+ +`fs:allow-read` + + + +Enables the read command without any pre-configured scope. + +
+ +`fs:deny-read` + + + +Denies the read command without any pre-configured scope. + +
+ +`fs:allow-read-dir` + + + +Enables the read_dir command without any pre-configured scope. + +
+ +`fs:deny-read-dir` + + + +Denies the read_dir command without any pre-configured scope. + +
+ +`fs:allow-read-file` + + + +Enables the read_file command without any pre-configured scope. + +
+ +`fs:deny-read-file` + + + +Denies the read_file command without any pre-configured scope. + +
+ +`fs:allow-read-text-file` + + + +Enables the read_text_file command without any pre-configured scope. + +
+ +`fs:deny-read-text-file` + + + +Denies the read_text_file command without any pre-configured scope. + +
+ +`fs:allow-read-text-file-lines` + + + +Enables the read_text_file_lines command without any pre-configured scope. + +
+ +`fs:deny-read-text-file-lines` + + + +Denies the read_text_file_lines command without any pre-configured scope. + +
+ +`fs:allow-read-text-file-lines-next` + + + +Enables the read_text_file_lines_next command without any pre-configured scope. + +
+ +`fs:deny-read-text-file-lines-next` + + + +Denies the read_text_file_lines_next command without any pre-configured scope. + +
+ +`fs:allow-remove` + + + +Enables the remove command without any pre-configured scope. + +
+ +`fs:deny-remove` + + + +Denies the remove command without any pre-configured scope. + +
+ +`fs:allow-rename` + + + +Enables the rename command without any pre-configured scope. + +
+ +`fs:deny-rename` + + + +Denies the rename command without any pre-configured scope. + +
+ +`fs:allow-seek` + + + +Enables the seek command without any pre-configured scope. + +
+ +`fs:deny-seek` + + + +Denies the seek command without any pre-configured scope. + +
+ +`fs:allow-size` + + + +Enables the size command without any pre-configured scope. + +
+ +`fs:deny-size` + + + +Denies the size command without any pre-configured scope. + +
+ +`fs:allow-start-accessing-security-scoped-resource` + + + +Enables the start_accessing_security_scoped_resource command without any pre-configured scope. + +
+ +`fs:deny-start-accessing-security-scoped-resource` + + + +Denies the start_accessing_security_scoped_resource command without any pre-configured scope. + +
+ +`fs:allow-stat` + + + +Enables the stat command without any pre-configured scope. + +
+ +`fs:deny-stat` + + + +Denies the stat command without any pre-configured scope. + +
+ +`fs:allow-stop-accessing-security-scoped-resource` + + + +Enables the stop_accessing_security_scoped_resource command without any pre-configured scope. + +
+ +`fs:deny-stop-accessing-security-scoped-resource` + + + +Denies the stop_accessing_security_scoped_resource command without any pre-configured scope. + +
+ +`fs:allow-truncate` + + + +Enables the truncate command without any pre-configured scope. + +
+ +`fs:deny-truncate` + + + +Denies the truncate command without any pre-configured scope. + +
+ +`fs:allow-unwatch` + + + +Enables the unwatch command without any pre-configured scope. + +
+ +`fs:deny-unwatch` + + + +Denies the unwatch command without any pre-configured scope. + +
+ +`fs:allow-watch` + + + +Enables the watch command without any pre-configured scope. + +
+ +`fs:deny-watch` + + + +Denies the watch command without any pre-configured scope. + +
+ +`fs:allow-write` + + + +Enables the write command without any pre-configured scope. + +
+ +`fs:deny-write` + + + +Denies the write command without any pre-configured scope. + +
+ +`fs:allow-write-file` + + + +Enables the write_file command without any pre-configured scope. + +
+ +`fs:deny-write-file` + + + +Denies the write_file command without any pre-configured scope. + +
+ +`fs:allow-write-text-file` + + + +Enables the write_text_file command without any pre-configured scope. + +
+ +`fs:deny-write-text-file` + + + +Denies the write_text_file command without any pre-configured scope. + +
+ +`fs:create-app-specific-dirs` + + + +This permissions allows to create the application specific directories. + + +
+ +`fs:deny-default` + + + +This denies access to dangerous Tauri relevant files and folders by default. + +
+ +`fs:deny-webview-data-linux` + + + +This denies access to the following paths + +- `$APPLOCALDATA/CacheStorage/**` +- `$APPLOCALDATA/cookies` +- `$APPLOCALDATA/hsts-storage.sqlite` +- `$APPLOCALDATA/serviceworkers/**` +- `$APPLOCALDATA/WebKitCache/**` +- `$APPLOCALDATA/databases/**` +- `$APPLOCALDATA/localstorage/**` +- `$APPLOCALDATA/mediakeys/**` +- `$APPLOCALDATA/storage/**` + +on linux as the webview data and configuration values are stored here. +Allowing access can lead to sensitive information disclosure and should be well considered. + +
+ +`fs:deny-webview-data-windows` + + + +This denies access to the +`$APPLOCALDATA/EBWebView` folder on windows as the webview data and configuration values are stored here. +Allowing access can lead to sensitive information disclosure and should be well considered. + +
+ +`fs:read-all` + + + +This enables all read related commands without any pre-configured accessible paths. + +
+ +`fs:read-app-specific-dirs-recursive` + + + +This permission allows recursive read functionality on the application +specific base directories. + + +
+ +`fs:read-dirs` + + + +This enables directory read and file metadata related commands without any pre-configured accessible paths. + +
+ +`fs:read-files` + + + +This enables file read related commands without any pre-configured accessible paths. + +
+ +`fs:read-meta` + + + +This enables all index or metadata related commands without any pre-configured accessible paths. + +
+ +`fs:scope` + + + +An empty permission you can use to modify the global scope. + +## Example + +```json +{ + "identifier": "read-documents", + "windows": ["main"], + "permissions": [ + "fs:allow-read", + { + "identifier": "fs:scope", + "allow": [ + "$APPDATA/documents/**/*" + ], + "deny": [ + "$APPDATA/documents/secret.txt" + ] + } + ] +} +``` + + +
+ +`fs:write-all` + + + +This enables all write related commands without any pre-configured accessible paths. + +
+ +`fs:write-files` + + + +This enables all file write related commands without any pre-configured accessible paths. + +
diff --git a/plugins/fs/permissions/create-app-specific-dirs.toml b/plugins/fs/permissions/create-app-specific-dirs.toml new file mode 100644 index 0000000..3c72293 --- /dev/null +++ b/plugins/fs/permissions/create-app-specific-dirs.toml @@ -0,0 +1,8 @@ +"$schema" = "schemas/schema.json" + +[[set]] +identifier = "create-app-specific-dirs" +description = """ +This permissions allows to create the application specific directories. +""" +permissions = ["allow-mkdir", "scope-app-index"] diff --git a/plugins/fs/permissions/default.toml b/plugins/fs/permissions/default.toml new file mode 100644 index 0000000..2afcc7d --- /dev/null +++ b/plugins/fs/permissions/default.toml @@ -0,0 +1,35 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This set of permissions describes the what kind of +file system access the `fs` plugin has enabled or denied by default. + +#### Granted Permissions + +This default permission set enables read access to the +application specific directories (AppConfig, AppData, AppLocalData, AppCache, +AppLog) and all files and sub directories created in it. +The location of these directories depends on the operating system, +where the application is run. + +In general these directories need to be manually created +by the application at runtime, before accessing files or folders +in it is possible. + +Therefore, it is also allowed to create all of these folders via +the `mkdir` command. + +#### Denied Permissions + +This default permission set prevents access to critical components +of the Tauri application by default. + +- On Windows the access to webview data folder `$APPLOCALDATA/EBWebView` is denied. +- On Linux the access to webview data paths inside `$APPLOCALDATA` are denied. +""" +permissions = [ + "create-app-specific-dirs", + "read-app-specific-dirs-recursive", + "deny-default", +] diff --git a/plugins/fs/permissions/deny-default.toml b/plugins/fs/permissions/deny-default.toml new file mode 100644 index 0000000..22d8186 --- /dev/null +++ b/plugins/fs/permissions/deny-default.toml @@ -0,0 +1,6 @@ +"$schema" = "schemas/schema.json" + +[[set]] +identifier = "deny-default" +description = "This denies access to dangerous Tauri relevant files and folders by default." +permissions = ["deny-webview-data-linux", "deny-webview-data-windows"] diff --git a/plugins/fs/permissions/deny-webview-data.toml b/plugins/fs/permissions/deny-webview-data.toml new file mode 100644 index 0000000..eec5991 --- /dev/null +++ b/plugins/fs/permissions/deny-webview-data.toml @@ -0,0 +1,48 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "deny-webview-data-linux" +description = """This denies access to the following paths + +- `$APPLOCALDATA/CacheStorage/**` +- `$APPLOCALDATA/cookies` +- `$APPLOCALDATA/hsts-storage.sqlite` +- `$APPLOCALDATA/serviceworkers/**` +- `$APPLOCALDATA/WebKitCache/**` +- `$APPLOCALDATA/databases/**` +- `$APPLOCALDATA/localstorage/**` +- `$APPLOCALDATA/mediakeys/**` +- `$APPLOCALDATA/storage/**` + +on linux as the webview data and configuration values are stored here. +Allowing access can lead to sensitive information disclosure and should be well considered.""" +platforms = ["linux"] + +[[permission.scope.deny]] +path = "$APPLOCALDATA/CacheStorage/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/cookies" +[[permission.scope.deny]] +path = "$APPLOCALDATA/hsts-storage.sqlite" +[[permission.scope.deny]] +path = "$APPLOCALDATA/serviceworkers/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/WebKitCache/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/databases/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/localstorage/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/mediakeys/**" +[[permission.scope.deny]] +path = "$APPLOCALDATA/storage/**" + +[[permission]] +identifier = "deny-webview-data-windows" +description = """This denies access to the +`$APPLOCALDATA/EBWebView` folder on windows as the webview data and configuration values are stored here. +Allowing access can lead to sensitive information disclosure and should be well considered.""" +platforms = ["windows"] + +[[permission.scope.deny]] +path = "$APPLOCALDATA/EBWebView/**" diff --git a/plugins/fs/permissions/read-all.toml b/plugins/fs/permissions/read-all.toml new file mode 100644 index 0000000..d43af5e --- /dev/null +++ b/plugins/fs/permissions/read-all.toml @@ -0,0 +1,21 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "read-all" +description = "This enables all read related commands without any pre-configured accessible paths." +commands.allow = [ + "read_dir", + "read_file", + "read", + "open", + "read_text_file", + "read_text_file_lines", + "read_text_file_lines_next", + "seek", + "stat", + "lstat", + "fstat", + "exists", + "watch", + "unwatch", +] diff --git a/plugins/fs/permissions/read-app-specific-dirs-recursive.toml b/plugins/fs/permissions/read-app-specific-dirs-recursive.toml new file mode 100644 index 0000000..8da8c1d --- /dev/null +++ b/plugins/fs/permissions/read-app-specific-dirs-recursive.toml @@ -0,0 +1,17 @@ +"$schema" = "schemas/schema.json" + +[[set]] +identifier = "read-app-specific-dirs-recursive" +description = """ +This permission allows recursive read functionality on the application +specific base directories. +""" +permissions = [ + "allow-read-dir", + "allow-read-file", + "allow-read-text-file", + "allow-read-text-file-lines", + "allow-read-text-file-lines-next", + "allow-exists", + "scope-app-recursive", +] diff --git a/plugins/fs/permissions/read-dirs.toml b/plugins/fs/permissions/read-dirs.toml new file mode 100644 index 0000000..eb38363 --- /dev/null +++ b/plugins/fs/permissions/read-dirs.toml @@ -0,0 +1,6 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "read-dirs" +description = "This enables directory read and file metadata related commands without any pre-configured accessible paths." +commands.allow = ["read_dir", "stat", "lstat", "fstat", "exists"] diff --git a/plugins/fs/permissions/read-files.toml b/plugins/fs/permissions/read-files.toml new file mode 100644 index 0000000..f268510 --- /dev/null +++ b/plugins/fs/permissions/read-files.toml @@ -0,0 +1,19 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "read-files" +description = "This enables file read related commands without any pre-configured accessible paths." +commands.allow = [ + "read_file", + "read", + "open", + "read_text_file", + "read_text_file_lines", + "read_text_file_lines_next", + "seek", + "stat", + "lstat", + "fstat", + "exists", + +] diff --git a/plugins/fs/permissions/read-meta.toml b/plugins/fs/permissions/read-meta.toml new file mode 100644 index 0000000..83024b0 --- /dev/null +++ b/plugins/fs/permissions/read-meta.toml @@ -0,0 +1,6 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "read-meta" +description = "This enables all index or metadata related commands without any pre-configured accessible paths." +commands.allow = ["read_dir", "stat", "lstat", "fstat", "exists", "size"] diff --git a/plugins/fs/permissions/scope.toml b/plugins/fs/permissions/scope.toml new file mode 100644 index 0000000..03ba275 --- /dev/null +++ b/plugins/fs/permissions/scope.toml @@ -0,0 +1,28 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "scope" +description = """ +An empty permission you can use to modify the global scope. + +## Example + +```json +{ + "identifier": "read-documents", + "windows": ["main"], + "permissions": [ + "fs:allow-read", + { + "identifier": "fs:scope", + "allow": [ + "$APPDATA/documents/**/*" + ], + "deny": [ + "$APPDATA/documents/secret.txt" + ] + } + ] +} +``` +""" diff --git a/plugins/fs/permissions/write-all.toml b/plugins/fs/permissions/write-all.toml new file mode 100644 index 0000000..c180278 --- /dev/null +++ b/plugins/fs/permissions/write-all.toml @@ -0,0 +1,17 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "write-all" +description = "This enables all write related commands without any pre-configured accessible paths." +commands.allow = [ + "mkdir", + "create", + "copy_file", + "remove", + "rename", + "truncate", + "ftruncate", + "write", + "write_file", + "write_text_file", +] diff --git a/plugins/fs/permissions/write-files.toml b/plugins/fs/permissions/write-files.toml new file mode 100644 index 0000000..2d6aeff --- /dev/null +++ b/plugins/fs/permissions/write-files.toml @@ -0,0 +1,16 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "write-files" +description = "This enables all file write related commands without any pre-configured accessible paths." +commands.allow = [ + "create", + "copy_file", + "remove", + "rename", + "truncate", + "ftruncate", + "write", + "write_file", + "write_text_file", +] diff --git a/plugins/fs/rollup.config.js b/plugins/fs/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/fs/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/fs/src/android.rs b/plugins/fs/src/android.rs new file mode 100644 index 0000000..d00b4fc --- /dev/null +++ b/plugins/fs/src/android.rs @@ -0,0 +1,92 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::{models::*, FilePath, OpenOptions}; + +const PLUGIN_IDENTIFIER: &str = "com.plugin.fs"; + +/// Access to the file system APIs on Android. +/// +/// In addition to regular file system paths, it can read `content://` URIs +/// and Android asset paths by resolving them with the Android plugin implementation. +/// +/// Retrieved with [`crate::FsExt::fs`]. +pub struct Fs(tauri::plugin::PluginHandle); + +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + let handle = api + .register_android_plugin(PLUGIN_IDENTIFIER, "FsPlugin") + .unwrap(); + Ok(Fs(handle)) +} + +impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file. + pub fn open>( + &self, + path: P, + opts: OpenOptions, + ) -> std::io::Result { + match path.into() { + FilePath::Url(u) => self + .resolve_content_uri(u.to_string(), opts.android_mode()) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("failed to open file: {e}"), + ) + }), + FilePath::Path(p) => { + // tauri::utils::platform::resources_dir() returns a PathBuf with the Android asset URI prefix + // we must resolve that file with the Android API + if p.strip_prefix(tauri::utils::platform::ANDROID_ASSET_PROTOCOL_URI_PREFIX) + .is_ok() + { + self.resolve_content_uri(p.to_string_lossy(), opts.android_mode()) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("failed to open file: {e}"), + ) + }) + } else { + std::fs::OpenOptions::from(opts).open(p) + } + } + } + } + + fn resolve_content_uri( + &self, + uri: impl Into, + mode: impl Into, + ) -> crate::Result { + let result = self.0.run_mobile_plugin::( + "getFileDescriptor", + GetFileDescriptorPayload { + uri: uri.into(), + mode: mode.into(), + }, + )?; + if let Some(fd) = result.fd { + Ok(unsafe { + use std::os::fd::FromRawFd; + std::fs::File::from_raw_fd(fd) + }) + } else { + unimplemented!() + } + } +} diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs new file mode 100644 index 0000000..fcc38f6 --- /dev/null +++ b/plugins/fs/src/commands.rs @@ -0,0 +1,1885 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// Copyright 2018-2023 the Deno authors. +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize, Serializer}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use tauri::{ + ipc::{CommandScope, GlobalScope}, + path::BaseDirectory, + utils::config::FsScope, + Manager, Resource, ResourceId, Runtime, Webview, +}; + +use std::{ + borrow::Cow, + fs::File, + io::{BufRead, BufReader, Read, Write}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, + str::FromStr, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{scope::Entry, Error, SafeFilePath}; + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + #[error(transparent)] + Plugin(#[from] Error), + #[error(transparent)] + Tauri(#[from] tauri::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + #[cfg(feature = "watch")] + #[error(transparent)] + Watcher(#[from] notify::Error), +} + +impl From for CommandError { + fn from(value: String) -> Self { + Self::Anyhow(anyhow::anyhow!(value)) + } +} + +impl From<&str> for CommandError { + fn from(value: &str) -> Self { + Self::Anyhow(anyhow::anyhow!(value.to_string())) + } +} + +impl Serialize for CommandError { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + if let Self::Anyhow(err) = self { + serializer.serialize_str(format!("{err:#}").as_ref()) + } else { + serializer.serialize_str(self.to_string().as_ref()) + } + } +} + +pub type CommandResult = std::result::Result; + +/// Represents either a plain PathBuf or a PathHandle that manages security-scoped resources. +pub enum PathKind { + /// A plain path that doesn't manage security-scoped resources. + #[cfg(mobile)] // only used on mobile + Path(PathBuf), + /// A path handle that manages security-scoped resources and will clean them up on drop. + Handle(PathHandle), +} + +impl PathKind { + /// Get a reference to the underlying path. + pub fn as_path(&self) -> &Path { + match self { + #[cfg(mobile)] + PathKind::Path(p) => p.as_ref(), + PathKind::Handle(h) => h.as_ref(), + } + } + + /// Get a reference to the underlying PathBuf. + pub fn as_path_buf(&self) -> &PathBuf { + match self { + #[cfg(mobile)] + PathKind::Path(p) => p, + PathKind::Handle(h) => h, + } + } +} + +impl AsRef for PathKind { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl AsRef for PathKind { + fn as_ref(&self) -> &PathBuf { + self.as_path_buf() + } +} + +/// A file handle that automatically stops accessing security-scoped resources on iOS when dropped. +pub struct FileHandle { + file: File, + path: PathKind, + #[cfg(target_os = "ios")] + path_: SafeFilePath, + #[cfg(target_os = "ios")] + app_handle: tauri::AppHandle, +} + +impl FileHandle { + /// Get the resolved path. + pub fn path(&self) -> &Path { + self.path.as_path() + } +} + +impl Deref for FileHandle { + type Target = File; + + fn deref(&self) -> &Self::Target { + &self.file + } +} + +impl DerefMut for FileHandle { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.file + } +} + +#[cfg(target_os = "ios")] +impl Drop for FileHandle { + fn drop(&mut self) { + // Only clean up if we have a plain PathBuf, not a PathHandle + // PathHandle will handle its own cleanup when it's dropped + if let PathKind::Path(_) = &self.path { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &self.path_ { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only clean up if we're tracking this resource + // If start_accessing_security_scoped_resource was used, it won't be in our tracking + // and we shouldn't interfere + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = + self.app_handle.state::(); + + // Only clean up if it's not tracked manually + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Stopping accessing security-scoped resource for URL: {url} on drop" + ); + let _ = self + .app_handle + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url(url.clone())); + security_scoped_resources.remove(url.as_str()); + } else { + log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); + } + } + } + } + } +} + +/// A path handle that automatically stops accessing security-scoped resources on iOS when dropped. +pub struct PathHandle { + path: PathBuf, + #[allow(dead_code)] // Used in Drop implementation + path_: SafeFilePath, + #[allow(dead_code)] // Used in Drop implementation + app_handle: tauri::AppHandle, +} + +impl PathHandle { + fn new(path: PathBuf, path_: SafeFilePath, app_handle: tauri::AppHandle) -> Self { + Self { + path, + path_, + app_handle, + } + } +} + +impl Deref for PathHandle { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.path + } +} + +impl AsRef for PathHandle { + fn as_ref(&self) -> &Path { + self.path.as_ref() + } +} + +impl AsRef for PathHandle { + fn as_ref(&self) -> &PathBuf { + &self.path + } +} + +#[cfg(target_os = "ios")] +impl Drop for PathHandle { + fn drop(&mut self) { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &self.path_ { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only clean up if we're tracking this resource (i.e., resolve_path started it) + // If start_accessing_security_scoped_resource was used, it won't be in our tracking + // and we shouldn't interfere + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = + self.app_handle.state::(); + + // Only clean up if it's not tracked manually + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Stopping accessing security-scoped resource for URL: {url} on drop" + ); + let _ = self + .app_handle + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url(url.clone())); + security_scoped_resources.remove(url.as_str()); + } else { + log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); + } + } + } + } +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BaseOptions { + base_dir: Option, +} + +#[tauri::command] +pub fn create( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + #[cfg(target_os = "ios")] + let path_ = path.clone(); + let resolved_path_handle = resolve_path( + "create", + &webview, + &global_scope, + &command_scope, + path, + options.and_then(|o| o.base_dir), + )?; + let file = File::create(&*resolved_path_handle).map_err(|e| { + format!( + "failed to create file at path: {} with error: {e}", + resolved_path_handle.display() + ) + })?; + #[cfg(target_os = "ios")] + let app_handle = webview.app_handle().clone(); + let file_handle = FileHandle { + file, + path: PathKind::Handle(resolved_path_handle), + #[cfg(target_os = "ios")] + path_, + #[cfg(target_os = "ios")] + app_handle, + }; + let rid = webview + .resources_table() + .add(StdFileResource::new(file_handle)); + Ok(rid) +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenOptions { + #[serde(flatten)] + base: BaseOptions, + #[serde(flatten)] + options: crate::OpenOptions, +} + +#[tauri::command] +pub fn open( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let file_handle = resolve_file( + "open", + &webview, + &global_scope, + &command_scope, + path, + if let Some(opts) = options { + OpenOptions { + base: opts.base, + options: opts.options, + } + } else { + OpenOptions { + base: BaseOptions { base_dir: None }, + options: crate::OpenOptions { + read: true, + write: false, + truncate: false, + create: false, + create_new: false, + append: false, + mode: None, + custom_flags: None, + }, + } + }, + )?; + + let rid = webview + .resources_table() + .add(StdFileResource::new(file_handle)); + + Ok(rid) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CopyFileOptions { + from_path_base_dir: Option, + to_path_base_dir: Option, +} + +#[tauri::command] +pub async fn copy_file( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + from_path: SafeFilePath, + to_path: SafeFilePath, + options: Option, +) -> CommandResult<()> { + let resolved_from_path = resolve_path( + "copy-file", + &webview, + &global_scope, + &command_scope, + from_path, + options.as_ref().and_then(|o| o.from_path_base_dir), + )?; + let resolved_to_path = resolve_path( + "copy-file", + &webview, + &global_scope, + &command_scope, + to_path, + options.as_ref().and_then(|o| o.to_path_base_dir), + )?; + std::fs::copy(&resolved_from_path, &resolved_to_path).map_err(|e| { + format!( + "failed to copy file from path: {}, to path: {} with error: {e}", + resolved_from_path.display(), + resolved_to_path.display() + ) + })?; + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MkdirOptions { + #[serde(flatten)] + base: BaseOptions, + #[allow(unused)] + mode: Option, + recursive: Option, +} + +#[tauri::command] +pub fn mkdir( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + "mkdir", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base.base_dir), + )?; + + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(options.as_ref().and_then(|o| o.recursive).unwrap_or(false)); + + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mode = options.as_ref().and_then(|o| o.mode).unwrap_or(0o777) & 0o777; + builder.mode(mode); + } + + builder + .create(&resolved_path) + .map_err(|e| { + format!( + "failed to create directory at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DirEntry { + pub name: String, + pub is_directory: bool, + pub is_file: bool, + pub is_symlink: bool, +} + +#[tauri::command] +pub async fn read_dir( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult> { + let resolved_path = resolve_path( + "read-dir", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + + let entries = std::fs::read_dir(&resolved_path).map_err(|e| { + format!( + "failed to read directory at path: {} with error: {e}", + resolved_path.display() + ) + })?; + + let entries = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().into_string().ok()?; + let metadata = entry.file_type(); + macro_rules! method_or_false { + ($method:ident) => { + if let Ok(metadata) = &metadata { + metadata.$method() + } else { + false + } + }; + } + Some(DirEntry { + name, + is_file: method_or_false!(is_file), + is_directory: method_or_false!(is_dir), + is_symlink: method_or_false!(is_symlink), + }) + }) + .collect(); + + Ok(entries) +} + +#[tauri::command] +pub async fn read( + webview: Webview, + rid: ResourceId, + len: usize, +) -> CommandResult { + let mut data = vec![0; len]; + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + let nread = StdFileResource::with_lock(&file, |file| file.read(&mut data)) + .map_err(|e| format!("faied to read bytes from file with error: {e}"))?; + + // This is an optimization to include the number of read bytes (as bigendian bytes) + // at the end of returned vector so we can use `tauri::ipc::Response` + // and avoid serialization overhead of separate values. + #[cfg(target_pointer_width = "16")] + let nread = { + let nread = nread.to_be_bytes(); + let mut out = [0; 8]; + out[6..].copy_from_slice(&nread); + out + }; + #[cfg(target_pointer_width = "32")] + let nread = { + let nread = nread.to_be_bytes(); + let mut out = [0; 8]; + out[4..].copy_from_slice(&nread); + out + }; + #[cfg(target_pointer_width = "64")] + let nread = nread.to_be_bytes(); + + data.extend(nread); + + Ok(tauri::ipc::Response::new(data)) +} + +async fn read_file_inner( + permission: &str, + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let mut file_handle = resolve_file( + permission, + &webview, + &global_scope, + &command_scope, + path, + OpenOptions { + base: BaseOptions { + base_dir: options.as_ref().and_then(|o| o.base_dir), + }, + options: crate::OpenOptions { + read: true, + ..Default::default() + }, + }, + )?; + + let mut contents = Vec::new(); + + file_handle.read_to_end(&mut contents).map_err(|e| { + format!( + "failed to read file as text at path: {} with error: {e}", + file_handle.path().display() + ) + })?; + + Ok(tauri::ipc::Response::new(contents)) +} + +#[tauri::command] +pub async fn read_file( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + read_file_inner( + "read-file", + webview, + global_scope, + command_scope, + path, + options, + ) + .await +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadTextFileOptions { + #[serde(flatten)] + base: BaseOptions, + encoding: Option, +} + +// TODO, remove in v3, rely on `read_file` command instead +#[tauri::command] +pub async fn read_text_file( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + read_file_inner( + "read-text-file", + webview, + global_scope, + command_scope, + path, + options, + ) + .await +} + +#[tauri::command] +pub fn read_text_file_lines( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + "read-text-file-lines", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base.base_dir), + )?; + + let file = File::open(&resolved_path).map_err(|e| { + format!( + "failed to open file at path: {} with error: {e}", + resolved_path.display() + ) + })?; + + let encoding = options.as_ref().and_then(|o| o.encoding.as_deref()); + let (lf_bytes, cr_bytes) = lf_cr_bytes_for_encoding_label(encoding); + let lines = BufReader::new(file); + let rid = webview + .resources_table() + .add(StdLinesResource::new(lines, lf_bytes, cr_bytes)); + + Ok(rid) +} + +/// Returns the byte sequences for LF (`\n`) and CR (`\r`) in the encoding label. +/// +/// The provided encoding label must be a normalized, lowercase string, +/// such as one obtained via `(new TextDecoder(encoding)).encoding`. +/// +/// +fn lf_cr_bytes_for_encoding_label(label: Option<&str>) -> (Vec, Vec) { + // Defaults to utf-8 + // https://developer.mozilla.org/ja/docs/Web/API/TextDecoder/TextDecoder#label + let label = label.unwrap_or("utf-8"); + + // Currently, according to the Web Standard, + // the ASCII-incompatible encodings are UTF-16LE/BE and ISO-2022-JP. + // However, ISO-2022-JP can still detect line breaks in the same way as ASCII. + // + // https://encoding.spec.whatwg.org/#security-background + if label == "utf-16le" { + return (vec![0x0A, 0x00], vec![0x0D, 0x00]); + } + if label == "utf-16be" { + return (vec![0x00, 0x0A], vec![0x00, 0x0D]); + } + + // ASCII-compatible + (vec![b'\n'], vec![b'\r']) +} + +#[tauri::command] +pub async fn read_text_file_lines_next( + webview: Webview, + rid: ResourceId, +) -> CommandResult { + let mut resource_table = webview.resources_table(); + let lines = resource_table.get::(rid)?; + + let ret = StdLinesResource::with_lock(&lines, |lines| -> CommandResult> { + // This is an optimization to include whether we finished iteration or not (1 or 0) + // at the end of returned vector so we can use `tauri::ipc::Response` + // and avoid serialization overhead of separate values. + match lines.next() { + Some(Ok(mut bytes)) => { + bytes.push(false as u8); + Ok(bytes) + } + Some(Err(_)) => Ok(vec![false as u8]), + None => { + resource_table.close(rid)?; + Ok(vec![true as u8]) + } + } + }); + + ret.map(tauri::ipc::Response::new) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveOptions { + #[serde(flatten)] + base: BaseOptions, + recursive: Option, +} + +#[tauri::command] +pub fn remove( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + "remove", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base.base_dir), + )?; + + let metadata = std::fs::symlink_metadata(&resolved_path).map_err(|e| { + format!( + "failed to get metadata of path: {} with error: {e}", + resolved_path.display() + ) + })?; + + let file_type = metadata.file_type(); + + // taken from deno source code: https://github.com/denoland/deno/blob/429759fe8b4207240709c240a8344d12a1e39566/runtime/ops/fs.rs#L728 + let res = if file_type.is_file() { + std::fs::remove_file(&resolved_path) + } else if options.as_ref().and_then(|o| o.recursive).unwrap_or(false) { + std::fs::remove_dir_all(&resolved_path) + } else if file_type.is_symlink() { + #[cfg(unix)] + { + std::fs::remove_file(&resolved_path) + } + #[cfg(not(unix))] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x00000010; + if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 { + std::fs::remove_dir(&resolved_path) + } else { + std::fs::remove_file(&resolved_path) + } + } + } else if file_type.is_dir() { + std::fs::remove_dir(&resolved_path) + } else { + // pipes, sockets, etc... + std::fs::remove_file(&resolved_path) + }; + + res.map_err(|e| { + format!( + "failed to remove path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameOptions { + new_path_base_dir: Option, + old_path_base_dir: Option, +} + +#[tauri::command] +pub fn rename( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + old_path: SafeFilePath, + new_path: SafeFilePath, + options: Option, +) -> CommandResult<()> { + let resolved_old_path = resolve_path( + "rename", + &webview, + &global_scope, + &command_scope, + old_path, + options.as_ref().and_then(|o| o.old_path_base_dir), + )?; + let resolved_new_path = resolve_path( + "rename", + &webview, + &global_scope, + &command_scope, + new_path, + options.as_ref().and_then(|o| o.new_path_base_dir), + )?; + std::fs::rename(&resolved_old_path, &resolved_new_path) + .map_err(|e| { + format!( + "failed to rename old path: {} to new path: {} with error: {e}", + resolved_old_path.display(), + resolved_new_path.display() + ) + }) + .map_err(Into::into) +} + +#[derive(Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] +#[repr(u16)] +pub enum SeekMode { + Start = 0, + Current = 1, + End = 2, +} + +#[tauri::command] +pub async fn seek( + webview: Webview, + rid: ResourceId, + offset: i64, + whence: SeekMode, +) -> CommandResult { + use std::io::{Seek, SeekFrom}; + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + StdFileResource::with_lock(&file, |file| { + file.seek(match whence { + SeekMode::Start => SeekFrom::Start(offset as u64), + SeekMode::Current => SeekFrom::Current(offset), + SeekMode::End => SeekFrom::End(offset), + }) + }) + .map_err(|e| format!("failed to seek file with error: {e}")) + .map_err(Into::into) +} + +#[cfg(target_os = "android")] +fn get_metadata std::io::Result>( + permission: &str, + metadata_fn: F, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + match path { + SafeFilePath::Url(url) => { + let file_handle = resolve_file( + permission, + webview, + global_scope, + command_scope, + SafeFilePath::Url(url), + OpenOptions { + base: BaseOptions { base_dir: None }, + options: crate::OpenOptions { + read: true, + ..Default::default() + }, + }, + )?; + file_handle.metadata().map_err(|e| { + format!( + "failed to get metadata of path: {} with error: {e}", + file_handle.path().display() + ) + .into() + }) + } + SafeFilePath::Path(p) => get_fs_metadata( + permission, + metadata_fn, + webview, + global_scope, + command_scope, + SafeFilePath::Path(p), + options, + ), + } +} + +#[cfg(not(target_os = "android"))] +fn get_metadata std::io::Result>( + permission: &str, + metadata_fn: F, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + get_fs_metadata( + permission, + metadata_fn, + webview, + global_scope, + command_scope, + path, + options, + ) +} + +fn get_fs_metadata std::io::Result>( + permission: &str, + metadata_fn: F, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + permission, + webview, + global_scope, + command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + let metadata = metadata_fn(&resolved_path).map_err(|e| { + format!( + "failed to get metadata of path: {} with error: {e}", + resolved_path.display() + ) + })?; + Ok(metadata) +} + +#[tauri::command] +pub fn stat( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let metadata = get_metadata( + "stat", + |p| std::fs::metadata(p), + &webview, + &global_scope, + &command_scope, + path, + options, + )?; + + Ok(get_stat(metadata)) +} + +#[tauri::command] +pub fn lstat( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let metadata = get_metadata( + "lstat", + |p| std::fs::symlink_metadata(p), + &webview, + &global_scope, + &command_scope, + path, + options, + )?; + Ok(get_stat(metadata)) +} + +#[tauri::command] +pub fn fstat(webview: Webview, rid: ResourceId) -> CommandResult { + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + let metadata = StdFileResource::with_lock(&file, |file| file.metadata()) + .map_err(|e| format!("failed to get metadata of file with error: {e}"))?; + Ok(get_stat(metadata)) +} + +#[tauri::command] +pub async fn truncate( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + len: Option, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + "truncate", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + let f = std::fs::OpenOptions::new() + .write(true) + .open(&resolved_path) + .map_err(|e| { + format!( + "failed to open file at path: {} with error: {e}", + resolved_path.display() + ) + })?; + f.set_len(len.unwrap_or(0)) + .map_err(|e| { + format!( + "failed to truncate file at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub async fn ftruncate( + webview: Webview, + rid: ResourceId, + len: Option, +) -> CommandResult<()> { + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + StdFileResource::with_lock(&file, |file| file.set_len(len.unwrap_or(0))) + .map_err(|e| format!("failed to truncate file with error: {e}")) + .map_err(Into::into) +} + +#[tauri::command] +pub async fn write( + webview: Webview, + rid: ResourceId, + data: Vec, +) -> CommandResult { + let file: std::sync::Arc> = webview.resources_table().get(rid)?; + StdFileResource::with_lock(&file, |file| file.write(&data)) + .map_err(|e| format!("failed to write bytes to file with error: {e}")) + .map_err(Into::into) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteFileOptions { + #[serde(flatten)] + base: BaseOptions, + #[serde(default)] + append: bool, + #[serde(default = "default_create_value")] + create: bool, + #[serde(default)] + create_new: bool, + #[allow(unused)] + mode: Option, +} + +fn default_create_value() -> bool { + true +} + +async fn write_file_inner( + permission: &str, + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + request: tauri::ipc::Request<'_>, +) -> CommandResult<()> { + let path = request + .headers() + .get("path") + .ok_or_else(|| anyhow::anyhow!("missing file path").into()) + .and_then(|p| { + percent_encoding::percent_decode(p.as_ref()) + .decode_utf8() + .map_err(|_| anyhow::anyhow!("path is not a valid UTF-8").into()) + }) + .and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?; + + let options: Option = request + .headers() + .get("options") + .and_then(|p| p.to_str().ok()) + .and_then(|opts| serde_json::from_str(opts).ok()); + + let mut file_handle = resolve_file( + permission, + &webview, + &global_scope, + &command_scope, + path, + if let Some(opts) = options { + OpenOptions { + base: opts.base, + options: crate::OpenOptions { + read: false, + write: true, + create: opts.create, + truncate: !opts.append, + append: opts.append, + create_new: opts.create_new, + mode: opts.mode, + custom_flags: None, + }, + } + } else { + OpenOptions { + base: BaseOptions { base_dir: None }, + options: crate::OpenOptions { + read: false, + write: true, + truncate: true, + create: true, + create_new: false, + append: false, + mode: None, + custom_flags: None, + }, + } + }, + )?; + + let data = match request.body() { + tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data), + tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned( + data.iter() + .flat_map(|v| v.as_number().and_then(|v| v.as_u64().map(|v| v as u8))) + .collect(), + ), + _ => return Err(anyhow::anyhow!("unexpected invoke body").into()), + }; + + file_handle + .write_all(&data) + .map_err(|e| { + format!( + "failed to write bytes to file at path: {} with error: {e}", + file_handle.path().display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub async fn write_file( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + request: tauri::ipc::Request<'_>, +) -> CommandResult<()> { + write_file_inner("write-file", webview, global_scope, command_scope, request).await +} + +// TODO, remove in v3, rely on `write_file` command instead +#[tauri::command] +pub async fn write_text_file( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + request: tauri::ipc::Request<'_>, +) -> CommandResult<()> { + write_file_inner( + "write-text-file", + webview, + global_scope, + command_scope, + request, + ) + .await +} + +#[tauri::command] +pub fn exists( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + "exists", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + Ok(resolved_path.exists()) +} + +#[tauri::command] +pub async fn size( + webview: Webview, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafeFilePath, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + "size", + &webview, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + + let metadata = resolved_path.metadata()?; + + if metadata.is_file() { + Ok(metadata.len()) + } else { + let size = get_dir_size(&resolved_path).map_err(|e| { + format!( + "failed to get size at path: {} with error: {e}", + resolved_path.display() + ) + })?; + + Ok(size) + } +} + +#[tauri::command] +pub fn start_accessing_security_scoped_resource( + webview: Webview, + path: SafeFilePath, +) -> CommandResult<()> { + #[cfg(target_os = "ios")] + { + use crate::FilePath; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &path { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only handle file URLs + if let FilePath::Url(url) = &file_path { + if url.scheme() == "file" { + use objc2_foundation::{NSString, NSURL}; + + let url_nsstring = NSString::from_str(url.as_str()); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Check if already active + let security_scoped_resources = + webview.state::(); + if security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Security-scoped resource already active for URL: {}", + url.as_str() + ); + return Ok(()); + } + + // Start accessing the security-scoped resource + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!( + "Started accessing security-scoped resource for URL: {}", + url.as_str() + ); + security_scoped_resources.track_manually(url.as_str().to_string()); + } else { + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url.as_str() + ); + return Err(CommandError::from(format!( + "Failed to start accessing security-scoped resource for URL: {}", + url.as_str() + ))); + } + } + } else { + return Err(CommandError::from(format!( + "Failed to create NSURL from URL: {}", + url.as_str() + ))); + } + } + } + Ok(()) + } + #[cfg(not(target_os = "ios"))] + { + // No-op on non-iOS platforms + let _ = webview; + let _ = path; + Ok(()) + } +} + +#[tauri::command] +pub fn stop_accessing_security_scoped_resource( + webview: Webview, + path: SafeFilePath, +) -> CommandResult<()> { + #[cfg(target_os = "ios")] + { + use crate::{FilePath, FsExt}; + // Convert SafeFilePath to FilePath + let file_path: FilePath = match &path { + SafeFilePath::Url(url) => FilePath::Url(url.clone()), + SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), + }; + + // Only handle file URLs + if let FilePath::Url(url) = file_path { + if url.scheme() == "file" { + let security_scoped_resources = webview.state::(); + + // Check if it's tracked + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + log::debug!( + "Security-scoped resource not tracked as active for URL: {}", + url.as_str() + ); + return Ok(()); + } + + // Stop accessing the security-scoped resource + webview + .fs() + .stop_accessing_security_scoped_resource(FilePath::Url(url.clone()))?; + + // Remove from tracking + security_scoped_resources.remove(url.as_str()); + log::debug!( + "Stopped accessing security-scoped resource for URL: {}", + url.as_str() + ); + } + } + Ok(()) + } + #[cfg(not(target_os = "ios"))] + { + // No-op on non-iOS platforms + let _ = webview; + let _ = path; + Ok(()) + } +} + +fn get_dir_size(path: &PathBuf) -> CommandResult { + let mut size = 0; + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + + if metadata.is_file() { + size += metadata.len(); + } else if metadata.is_dir() { + size += get_dir_size(&entry.path())?; + } + } + + Ok(size) +} + +#[cfg(desktop)] +pub fn resolve_file( + permission: &str, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + open_options: OpenOptions, +) -> CommandResult> { + resolve_file_in_fs( + permission, + webview, + global_scope, + command_scope, + path, + open_options, + ) +} + +fn resolve_file_in_fs( + permission: &str, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + open_options: OpenOptions, +) -> CommandResult> { + #[cfg(target_os = "ios")] + let path_ = path.clone(); + let resolved_path_handle = resolve_path( + permission, + webview, + global_scope, + command_scope, + path, + open_options.base.base_dir, + )?; + + let file = std::fs::OpenOptions::from(open_options.options) + .open(&*resolved_path_handle) + .map_err(|e| { + format!( + "failed to open file at path: {} with error: {e}", + resolved_path_handle.display() + ) + })?; + + #[cfg(target_os = "ios")] + let app_handle = webview.app_handle().clone(); + Ok(FileHandle { + file, + path: PathKind::Handle(resolved_path_handle), + #[cfg(target_os = "ios")] + path_, + #[cfg(target_os = "ios")] + app_handle, + }) +} + +#[cfg(mobile)] +pub fn resolve_file( + permission: &str, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + open_options: OpenOptions, +) -> CommandResult> { + use crate::FsExt; + + #[cfg(target_os = "ios")] + let path_ = path.clone(); + match path { + SafeFilePath::Url(url) => { + let resolved_path = url.as_str().into(); + let file = webview + .fs() + .open(SafeFilePath::Url(url.clone()), open_options.options)?; + #[cfg(target_os = "ios")] + let app_handle = webview.app_handle().clone(); + Ok(FileHandle { + file, + path: PathKind::Path(resolved_path), + #[cfg(target_os = "ios")] + path_, + #[cfg(target_os = "ios")] + app_handle, + }) + } + SafeFilePath::Path(path) => resolve_file_in_fs( + permission, + webview, + global_scope, + command_scope, + SafeFilePath::Path(path), + open_options, + ), + } +} + +pub fn resolve_path( + permission: &str, + webview: &Webview, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafeFilePath, + base_dir: Option, +) -> CommandResult> { + let path_ = path.clone(); + // On iOS, start accessing security-scoped resource if the path is a file URL + // Only if it hasn't been started already via start_accessing_security_scoped_resource + #[cfg(target_os = "ios")] + if let SafeFilePath::Url(url) = &path { + if url.scheme() == "file" { + use objc2_foundation::{NSString, NSURL}; + + let security_scoped_resources = webview.state::(); + + // Check if already active (started via start_accessing_security_scoped_resource) + if !security_scoped_resources.is_tracked_manually(url.as_str()) { + let url_nsstring = NSString::from_str(url.as_str()); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Start accessing the security-scoped resource + // This is required for files outside the app's sandbox (e.g., from file picker) + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!("Started accessing security-scoped resource for URL: {} (via resolve_path)", url.as_str()); + // Track it so we know to clean it up + security_scoped_resources.track_manually(url.as_str().to_string()); + } else { + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url.as_str() + ); + } + } + } else { + log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url.as_str()); + } + } else { + log::debug!("Security-scoped resource already active for URL: {} (started via start_accessing_security_scoped_resource), skipping", url.as_str()); + } + } + } + + let path = path.into_path()?; + let resolved_path = if let Some(base_dir) = base_dir { + webview.path().resolve(&path, base_dir)? + } else { + path + }; + + let fs_scope = webview.state::(); + + let scope = tauri::scope::fs::Scope::new( + webview, + &FsScope::Scope { + allow: global_scope + .allows() + .iter() + .filter_map(|e| e.path.clone()) + .chain(command_scope.allows().iter().filter_map(|e| e.path.clone())) + .collect(), + deny: global_scope + .denies() + .iter() + .filter_map(|e| e.path.clone()) + .chain(command_scope.denies().iter().filter_map(|e| e.path.clone())) + .collect(), + require_literal_leading_dot: fs_scope.require_literal_leading_dot, + }, + )?; + + let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix)); + + if is_forbidden(&fs_scope.scope, &resolved_path, require_literal_leading_dot) + || is_forbidden(&scope, &resolved_path, require_literal_leading_dot) + { + return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); + } + + if fs_scope.scope.is_allowed(&resolved_path) || scope.is_allowed(&resolved_path) { + let app_handle = webview.app_handle().clone(); + Ok(PathHandle::new(resolved_path, path_, app_handle)) + } else { + #[cfg(not(debug_assertions))] + return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); + + #[cfg(debug_assertions)] + Err( + anyhow::anyhow!( + "forbidden path: {}, maybe it is not allowed on the scope for `allow-{permission}` permission in your capability file", + resolved_path.display() + ) + ) + .map_err(Into::into) + } +} + +fn is_forbidden>( + scope: &tauri::fs::Scope, + path: P, + require_literal_leading_dot: bool, +) -> bool { + let path = path.as_ref(); + let path = if path.is_symlink() { + match std::fs::read_link(path) { + Ok(p) => p, + Err(_) => return false, + } + } else { + path.to_path_buf() + }; + let path = if !path.exists() { + crate::Result::Ok(path) + } else { + std::fs::canonicalize(path).map_err(Into::into) + }; + + if let Ok(path) = path { + let path: PathBuf = path.components().collect(); + scope.forbidden_patterns().iter().any(|p| { + p.matches_path_with( + &path, + glob::MatchOptions { + // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt` + // see: + require_literal_separator: true, + require_literal_leading_dot, + ..Default::default() + }, + ) + }) + } else { + false + } +} + +struct StdFileResource(Mutex>); + +impl StdFileResource { + fn new(file_handle: FileHandle) -> Self { + Self(Mutex::new(file_handle)) + } + + fn with_lock Ret>(&self, mut f: F) -> Ret { + let mut file_handle = self.0.lock().unwrap(); + f(&mut file_handle) + } +} + +impl Resource for StdFileResource {} + +/// Same as [std::io::Lines] but with bytes +struct LinesBytes { + bytes: T, + lf_bytes: Vec, + cr_bytes: Vec, +} + +impl LinesBytes { + fn new(bytes: T, lf_bytes: Vec, cr_bytes: Vec) -> Self { + LinesBytes { + bytes, + lf_bytes, + cr_bytes, + } + } +} + +impl Iterator for LinesBytes { + type Item = std::io::Result>; + + fn next(&mut self) -> Option>> { + let mut buf = Vec::new(); + // Search for '\n' + match read_until_bytes(&mut self.bytes, &self.lf_bytes, &mut buf) { + Ok(0) => None, + Ok(_n) => { + // Remove '\n' or '\r\n' + if buf.ends_with(&self.lf_bytes) { + buf.truncate(buf.len() - self.lf_bytes.len()); + if buf.ends_with(&self.cr_bytes) { + buf.truncate(buf.len() - self.cr_bytes.len()); + } + } + + Some(Ok(buf)) + } + Err(e) => Some(Err(e)), + } + } +} + +fn read_until_bytes( + r: &mut impl BufRead, + bytes: &[u8], + buf: &mut Vec, +) -> std::io::Result { + let last_byte = *bytes + .last() + .ok_or_else(|| std::io::Error::other("invalid empty bytes"))?; + + if bytes.len() == 1 { + return r.read_until(last_byte, buf); + } + + let mut total_n = 0; + loop { + let n = r.read_until(last_byte, buf)?; + total_n += n; + + if n == 0 || buf.ends_with(bytes) { + return Ok(total_n); + } + } +} + +struct StdLinesResource(Mutex>>); + +impl StdLinesResource { + fn new(lines: BufReader, lf_bytes: Vec, cr_bytes: Vec) -> Self { + Self(Mutex::new(LinesBytes::new(lines, lf_bytes, cr_bytes))) + } + + fn with_lock>) -> R>(&self, mut f: F) -> R { + let mut lines = self.0.lock().unwrap(); + f(&mut lines) + } +} + +impl Resource for StdLinesResource {} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913 +#[inline] +fn to_msec(maybe_time: std::result::Result) -> Option { + match maybe_time { + Ok(time) => { + let msec = time + .duration_since(UNIX_EPOCH) + .map(|t| t.as_millis() as u64) + .unwrap_or_else(|err| err.duration().as_millis() as u64); + Some(msec) + } + Err(_) => None, + } +} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L926 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileInfo { + is_file: bool, + is_directory: bool, + is_symlink: bool, + size: u64, + // In milliseconds, like JavaScript. Available on both Unix or Windows. + mtime: Option, + atime: Option, + birthtime: Option, + readonly: bool, + // Following are only valid under Windows. + file_attribues: Option, + // Following are only valid under Unix. + dev: Option, + ino: Option, + mode: Option, + nlink: Option, + uid: Option, + gid: Option, + rdev: Option, + blksize: Option, + blocks: Option, +} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L950 +#[inline(always)] +fn get_stat(metadata: std::fs::Metadata) -> FileInfo { + // Unix stat member (number types only). 0 if not on unix. + macro_rules! usm { + ($member:ident) => {{ + #[cfg(unix)] + { + Some(metadata.$member()) + } + #[cfg(not(unix))] + { + None + } + }}; + } + + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + #[cfg(windows)] + use std::os::windows::fs::MetadataExt; + FileInfo { + is_file: metadata.is_file(), + is_directory: metadata.is_dir(), + is_symlink: metadata.file_type().is_symlink(), + size: metadata.len(), + // In milliseconds, like JavaScript. Available on both Unix or Windows. + mtime: to_msec(metadata.modified()), + atime: to_msec(metadata.accessed()), + birthtime: to_msec(metadata.created()), + readonly: metadata.permissions().readonly(), + // Following are only valid under Windows. + #[cfg(windows)] + file_attribues: Some(metadata.file_attributes()), + #[cfg(not(windows))] + file_attribues: None, + // Following are only valid under Unix. + dev: usm!(dev), + ino: usm!(ino), + mode: usm!(mode), + nlink: usm!(nlink), + uid: usm!(uid), + gid: usm!(gid), + rdev: usm!(rdev), + blksize: usm!(blksize), + blocks: usm!(blocks), + } +} + +#[cfg(test)] +mod test { + use std::io::{BufRead, BufReader}; + + use super::LinesBytes; + + #[test] + fn safe_file_path_parse() { + use super::SafeFilePath; + + assert!(matches!( + serde_json::from_str::("\"C:/Users\""), + Ok(SafeFilePath::Path(_)) + )); + assert!(matches!( + serde_json::from_str::("\"file:///C:/Users\""), + Ok(SafeFilePath::Url(_)) + )); + } + + #[test] + fn test_lines_bytes() { + // UTF-8 + { + let base = String::from("line 1\nline2\nline 3\r\nline 4"); + let bytes = base.as_bytes(); + + let string1 = base.lines().collect::(); + let string2 = BufReader::new(bytes) + .lines() + .map_while(Result::ok) + .collect::(); + let string3 = LinesBytes::new(BufReader::new(bytes), vec![b'\n'], vec![b'\r']) + .flatten() + .flat_map(String::from_utf8) + .collect::(); + + assert_eq!(string1, string2); + assert_eq!(string1, string3); + assert_eq!(string2, string3); + } + + // UTF-16 LE + { + fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() + } + + let base = String::from("line 1\nline2\nline 3\r\nline 4\n"); + let bytes = utf16(&base); + + let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r")); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4"))); + assert!(lines.next().is_none()); + } + + // UTF-16 BE + { + fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(|u| u.to_be_bytes()).collect() + } + + // ਗ (U+0A17) encodes to 0x0A 0x17, + // which contains 0x0A but is not a line feed (U+000A = 0x00 0x0A). + let base = String::from("line 1\nline2ਗ\nline 3\r\nline 4"); + let bytes = utf16(&base); + + let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r")); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2ਗ"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3"))); + assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4"))); + assert!(lines.next().is_none()); + } + } +} diff --git a/plugins/fs/src/config.rs b/plugins/fs/src/config.rs new file mode 100644 index 0000000..db3bae4 --- /dev/null +++ b/plugins/fs/src/config.rs @@ -0,0 +1,19 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + /// Whether or not paths that contain components that start with a `.` + /// will require that `.` appears literally in the pattern; `*`, `?`, `**`, + /// or `[...]` will not match. This is useful because such files are + /// conventionally considered hidden on Unix systems and it might be + /// desirable to skip them when listing files. + /// + /// Defaults to `true` on Unix systems and `false` on Windows + // dotfiles are not supposed to be exposed by default on unix + pub require_literal_leading_dot: Option, +} diff --git a/plugins/fs/src/desktop.rs b/plugins/fs/src/desktop.rs new file mode 100644 index 0000000..68baa2c --- /dev/null +++ b/plugins/fs/src/desktop.rs @@ -0,0 +1,44 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use tauri::{AppHandle, Runtime}; + +use crate::{FilePath, OpenOptions}; + +/// Access to the file system APIs on desktop. +/// +/// Retrieved with [`crate::FsExt::fs`]. +pub struct Fs(pub(crate) AppHandle); + +fn path_or_err>(p: P) -> std::io::Result { + match p.into() { + FilePath::Path(p) => Ok(p), + FilePath::Url(u) if u.scheme() == "file" => u + .to_file_path() + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file URL")), + FilePath::Url(_) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot use a URL to load files on desktop and iOS", + )), + } +} + +impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file. + pub fn open>( + &self, + path: P, + opts: OpenOptions, + ) -> std::io::Result { + let path = path_or_err(path)?; + std::fs::OpenOptions::from(opts).open(path) + } +} diff --git a/plugins/fs/src/error.rs b/plugins/fs/src/error.rs new file mode 100644 index 0000000..cb9685f --- /dev/null +++ b/plugins/fs/src/error.rs @@ -0,0 +1,51 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +/// Errors that can happen while using the file system plugin. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// JSON serialization or deserialization error. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// Error from the Tauri APIs, usually raised while resolving a path or a scope entry. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// Error from the underlying file system operation. + #[error(transparent)] + Io(#[from] std::io::Error), + /// The path is denied by the plugin scope or is not allowed by it. + #[error("forbidden path: {0}")] + PathForbidden(PathBuf), + /// Invalid glob pattern. + #[error("invalid glob pattern: {0}")] + GlobPattern(#[from] glob::PatternError), + /// Watcher error. + #[cfg(feature = "watch")] + #[error(transparent)] + Watch(#[from] notify::Error), + /// Error invoking the Android plugin implementation. + #[cfg(target_os = "android")] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The URL cannot be converted to a file system path. + #[error("URL is not a valid path")] + InvalidPathUrl, + /// The path is not safe to use, for instance because it traverses parent directories. + #[error("Unsafe PathBuf: {0}")] + UnsafePathBuf(&'static str), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/fs/src/file_path.rs b/plugins/fs/src/file_path.rs new file mode 100644 index 0000000..6316a24 --- /dev/null +++ b/plugins/fs/src/file_path.rs @@ -0,0 +1,308 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + convert::Infallible, + path::{Path, PathBuf}, + str::FromStr, +}; + +use serde::Serialize; +use tauri::path::SafePathBuf; + +use crate::{Error, Result}; + +/// Represents either a filesystem path or a URI pointing to a file +/// such as `file://` URIs or Android `content://` URIs. +#[derive(Debug, Serialize, Clone)] +#[serde(untagged)] +pub enum FilePath { + /// `file://` URIs or Android `content://` URIs. + Url(url::Url), + /// Regular [`PathBuf`] + Path(PathBuf), +} + +/// Represents either a safe filesystem path or a URI pointing to a file +/// such as `file://` URIs or Android `content://` URIs. +#[derive(Debug, Clone, Serialize)] +pub enum SafeFilePath { + /// `file://` URIs or Android `content://` URIs. + Url(url::Url), + /// Safe [`PathBuf`], see [`SafePathBuf``]. + Path(SafePathBuf), +} + +impl FilePath { + /// Get a reference to the contained [`Path`] if the variant is [`FilePath::Path`]. + /// + /// Use [`FilePath::into_path`] to try to convert the [`FilePath::Url`] variant as well. + #[inline] + pub fn as_path(&self) -> Option<&Path> { + match self { + Self::Url(_) => None, + Self::Path(p) => Some(p), + } + } + + /// Try to convert into [`PathBuf`] if possible. + /// + /// This calls [`Url::to_file_path`](url::Url::to_file_path) if the variant is [`FilePath::Url`], + /// otherwise returns the contained [PathBuf] as is. + #[inline] + pub fn into_path(self) -> Result { + match self { + Self::Url(url) => url.to_file_path().map_err(|_| Error::InvalidPathUrl), + Self::Path(p) => Ok(p), + } + } + + /// Takes the contained [`PathBuf`] if the variant is [`FilePath::Path`], + /// and when possible, converts Windows UNC paths to regular paths. + #[inline] + pub fn simplified(self) -> Self { + match self { + Self::Url(url) => Self::Url(url), + Self::Path(p) => Self::Path(dunce::simplified(&p).to_path_buf()), + } + } +} + +impl SafeFilePath { + /// Get a reference to the contained [`Path`] if the variant is [`SafeFilePath::Path`]. + /// + /// Use [`SafeFilePath::into_path`] to try to convert the [`SafeFilePath::Url`] variant as well. + #[inline] + pub fn as_path(&self) -> Option<&Path> { + match self { + Self::Url(_) => None, + Self::Path(p) => Some(p.as_ref()), + } + } + + /// Try to convert into [`PathBuf`] if possible. + /// + /// This calls [`Url::to_file_path`](url::Url::to_file_path) if the variant is [`SafeFilePath::Url`], + /// otherwise returns the contained [PathBuf] as is. + #[inline] + pub fn into_path(self) -> Result { + match self { + Self::Url(url) => url.to_file_path().map_err(|_| Error::InvalidPathUrl), + Self::Path(p) => Ok(p.as_ref().to_owned()), + } + } + + /// Takes the contained [`PathBuf`] if the variant is [`SafeFilePath::Path`], + /// and when possible, converts Windows UNC paths to regular paths. + #[inline] + pub fn simplified(self) -> Self { + match self { + Self::Url(url) => Self::Url(url), + Self::Path(p) => { + // Safe to unwrap since it was a safe file path already + Self::Path(SafePathBuf::new(dunce::simplified(p.as_ref()).to_path_buf()).unwrap()) + } + } + } +} + +impl std::fmt::Display for FilePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Url(u) => u.fmt(f), + Self::Path(p) => p.display().fmt(f), + } + } +} + +impl std::fmt::Display for SafeFilePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Url(u) => u.fmt(f), + Self::Path(p) => p.display().fmt(f), + } + } +} + +impl<'de> serde::Deserialize<'de> for FilePath { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct FilePathVisitor; + + impl serde::de::Visitor<'_> for FilePathVisitor { + type Value = FilePath; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string representing an file URL or a path") + } + + fn visit_str(self, s: &str) -> std::result::Result + where + E: serde::de::Error, + { + FilePath::from_str(s).map_err(|e| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Str(s), + &e.to_string().as_str(), + ) + }) + } + } + + deserializer.deserialize_str(FilePathVisitor) + } +} + +impl<'de> serde::Deserialize<'de> for SafeFilePath { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct SafeFilePathVisitor; + + impl serde::de::Visitor<'_> for SafeFilePathVisitor { + type Value = SafeFilePath; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string representing an file URL or a path") + } + + fn visit_str(self, s: &str) -> std::result::Result + where + E: serde::de::Error, + { + SafeFilePath::from_str(s).map_err(|e| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Str(s), + &e.to_string().as_str(), + ) + }) + } + } + + deserializer.deserialize_str(SafeFilePathVisitor) + } +} + +impl FromStr for FilePath { + type Err = Infallible; + fn from_str(s: &str) -> std::result::Result { + if let Ok(url) = url::Url::from_str(s) { + if url.scheme().len() != 1 { + return Ok(Self::Url(url)); + } + } + Ok(Self::Path(PathBuf::from(s))) + } +} + +impl FromStr for SafeFilePath { + type Err = Error; + fn from_str(s: &str) -> Result { + if let Ok(url) = url::Url::from_str(s) { + if url.scheme().len() != 1 { + return Ok(Self::Url(url)); + } + } + + SafePathBuf::new(s.into()) + .map(SafeFilePath::Path) + .map_err(Error::UnsafePathBuf) + } +} + +impl From for FilePath { + fn from(value: PathBuf) -> Self { + Self::Path(value) + } +} + +impl TryFrom for SafeFilePath { + type Error = Error; + fn try_from(value: PathBuf) -> Result { + SafePathBuf::new(value) + .map(SafeFilePath::Path) + .map_err(Error::UnsafePathBuf) + } +} + +impl From<&Path> for FilePath { + fn from(value: &Path) -> Self { + Self::Path(value.to_owned()) + } +} + +impl TryFrom<&Path> for SafeFilePath { + type Error = Error; + fn try_from(value: &Path) -> Result { + SafePathBuf::new(value.to_path_buf()) + .map(SafeFilePath::Path) + .map_err(Error::UnsafePathBuf) + } +} + +impl From<&PathBuf> for FilePath { + fn from(value: &PathBuf) -> Self { + Self::Path(value.to_owned()) + } +} + +impl TryFrom<&PathBuf> for SafeFilePath { + type Error = Error; + fn try_from(value: &PathBuf) -> Result { + SafePathBuf::new(value.to_owned()) + .map(SafeFilePath::Path) + .map_err(Error::UnsafePathBuf) + } +} + +impl From for FilePath { + fn from(value: url::Url) -> Self { + Self::Url(value) + } +} + +impl From for SafeFilePath { + fn from(value: url::Url) -> Self { + Self::Url(value) + } +} + +impl TryFrom for PathBuf { + type Error = Error; + fn try_from(value: FilePath) -> Result { + value.into_path() + } +} + +impl TryFrom for PathBuf { + type Error = Error; + fn try_from(value: SafeFilePath) -> Result { + value.into_path() + } +} + +impl From for FilePath { + fn from(value: SafeFilePath) -> Self { + match value { + SafeFilePath::Url(url) => FilePath::Url(url), + SafeFilePath::Path(p) => FilePath::Path(p.as_ref().to_owned()), + } + } +} + +impl TryFrom for SafeFilePath { + type Error = Error; + + fn try_from(value: FilePath) -> Result { + match value { + FilePath::Url(url) => Ok(SafeFilePath::Url(url)), + FilePath::Path(p) => SafePathBuf::new(p) + .map(SafeFilePath::Path) + .map_err(Error::UnsafePathBuf), + } + } +} diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs new file mode 100644 index 0000000..fa89361 --- /dev/null +++ b/plugins/fs/src/ios.rs @@ -0,0 +1,142 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::{FilePath, OpenOptions}; + +/// Access to the file system APIs on iOS. +/// +/// Opening a `file://` URL automatically starts accessing the matching security-scoped resource. +/// +/// Retrieved with [`crate::FsExt::fs`]. +pub struct Fs { + _phantom: std::marker::PhantomData R>, +} + +pub fn init( + _app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Fs { + _phantom: std::marker::PhantomData, + }) +} + +impl Fs { + /// Open a file. + /// + /// # Platform-specific + /// + /// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL. + /// You must call [`Self::stop_accessing_security_scoped_resource`] when you're done accessing the file. + pub fn open>( + &self, + path: P, + opts: OpenOptions, + ) -> std::io::Result { + use objc2_foundation::{NSString, NSURL}; + + match path.into() { + FilePath::Url(url) if url.scheme() == "file" => { + // Handle security-scoped URLs on iOS + let url_string = url.as_str(); + let url_nsstring = NSString::from_str(url_string); + + // Create NSURL from the URL string + // URLWithString may return None for invalid URLs, but file:// URLs should be valid + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Start accessing the security-scoped resource + // This is required for files outside the app's sandbox (e.g., from file picker) + // Note: We don't call stopAccessingSecurityScopedResource here because + // the file handle needs to remain accessible while the File is in use. + // The access will be automatically stopped when the app is backgrounded or terminated. + unsafe { + let success = ns_url.startAccessingSecurityScopedResource(); + if success { + log::debug!( + "Started accessing security-scoped resource for URL: {}", + url_string + ); + } else { + log::warn!( + "Failed to start accessing security-scoped resource for URL: {}", + url_string + ); + } + } + } else { + log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url_string); + } + + // Convert URL to path and open the file + let path = url.to_file_path().map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file URL") + })?; + std::fs::OpenOptions::from(opts).open(path) + } + FilePath::Url(_) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot use a non-file URL to load files on iOS", + )), + FilePath::Path(p) => { + // Regular path, no security-scoped resource handling needed + std::fs::OpenOptions::from(opts).open(p) + } + } + } + + /// Stops accessing a security-scoped resource for the given file path or URL. + /// This should be called when you're done accessing a file that was opened + /// using a security-scoped URL (e.g., from a file picker). + /// + /// # Arguments + /// + /// * `path` - A file path or URL that was previously accessed via `startAccessingSecurityScopedResource` + /// + /// # Returns + /// + /// Returns `Ok(())` if successful, or an error if the path/URL is invalid or not a file URL. + pub fn stop_accessing_security_scoped_resource>( + &self, + path: P, + ) -> crate::Result<()> { + use objc2_foundation::{NSString, NSURL}; + + let file_path = path.into(); + let url_string = match file_path { + FilePath::Url(url) => { + if url.scheme() != "file" { + return Err(crate::Error::InvalidPathUrl); + } + url.as_str().to_string() + } + FilePath::Path(p) => { + // Convert path to file URL + url::Url::from_file_path(&p) + .map_err(|_| crate::Error::InvalidPathUrl)? + .as_str() + .to_string() + } + }; + + let url_nsstring = NSString::from_str(&url_string); + let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; + if let Some(ns_url) = ns_url { + // Stop accessing the security-scoped resource + unsafe { + ns_url.stopAccessingSecurityScopedResource(); + } + } else { + return Err(crate::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "failed to create NSURL from URL", + ))); + } + + Ok(()) + } +} diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs new file mode 100644 index 0000000..cac582b --- /dev/null +++ b/plugins/fs/src/lib.rs @@ -0,0 +1,632 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Access the file system. +//! +//! ## Cargo features +//! +//! - **watch**: Enables the `watch` command backed by [`notify`](http://crates.io/crates/notify). + +// TODO(v3): consider redesign the API to implement automatic stopAccessingSecurityScopedResource on iOS +// this likely requires returning a handle to a resource so we can impl Drop for it + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use std::io::Read; +#[cfg(target_os = "ios")] +use std::sync::Mutex; + +use serde::Deserialize; +use tauri::{ + ipc::ScopeObject, + plugin::{Builder as PluginBuilder, TauriPlugin}, + utils::{acl::Value, config::FsScope}, + AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, +}; + +#[cfg(target_os = "android")] +mod android; +mod commands; +mod config; +#[cfg(desktop)] +mod desktop; +mod error; +mod file_path; +#[cfg(target_os = "ios")] +mod ios; +#[cfg(target_os = "android")] +mod models; +mod scope; +#[cfg(feature = "watch")] +mod watcher; + +#[cfg(target_os = "android")] +pub use android::Fs; +#[cfg(desktop)] +pub use desktop::Fs; +#[cfg(target_os = "ios")] +pub use ios::Fs; + +pub use error::Error; + +pub use file_path::FilePath; +pub use file_path::SafeFilePath; + +type Result = std::result::Result; + +/// Options and flags which can be used to configure how a file is opened. +/// +/// This builder exposes the ability to configure how a [`std::fs::File`] is opened and +/// what operations are permitted on the open file. Build it with [`OpenOptions::new`], +/// chain calls to the setter methods and pass it to [`Fs::open`]. +/// +/// The `read` option defaults to `true`, every other option defaults to `false`. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenOptions { + #[serde(default = "default_true")] + read: bool, + #[serde(default)] + write: bool, + #[serde(default)] + append: bool, + #[serde(default)] + truncate: bool, + #[serde(default)] + create: bool, + #[serde(default)] + create_new: bool, + #[serde(default)] + #[allow(unused)] + mode: Option, + #[serde(default)] + #[allow(unused)] + custom_flags: Option, +} + +fn default_true() -> bool { + true +} + +impl From for std::fs::OpenOptions { + fn from(open_options: OpenOptions) -> Self { + let mut opts = std::fs::OpenOptions::new(); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + if let Some(mode) = open_options.mode { + opts.mode(mode); + } + if let Some(flags) = open_options.custom_flags { + opts.custom_flags(flags); + } + } + + opts.read(open_options.read) + .write(open_options.write) + .create(open_options.create) + .append(open_options.append) + .truncate(open_options.truncate) + .create_new(open_options.create_new); + + opts + } +} + +impl OpenOptions { + /// Creates a blank new set of options ready for configuration. + /// + /// All options are initially set to `false`. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.read(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the option for read access. + /// + /// This option, when true, will indicate that the file should be + /// `read`-able if opened. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.read(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn read(&mut self, read: bool) -> &mut Self { + self.read = read; + self + } + + /// Sets the option for write access. + /// + /// This option, when true, will indicate that the file should be + /// `write`-able if opened. + /// + /// If the file already exists, any write calls on it will overwrite its + /// contents, without truncating it. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.write(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn write(&mut self, write: bool) -> &mut Self { + self.write = write; + self + } + + /// Sets the option for the append mode. + /// + /// This option, when true, means that writes will append to a file instead + /// of overwriting previous contents. + /// Note that setting `.write(true).append(true)` has the same effect as + /// setting only `.append(true)`. + /// + /// Append mode guarantees that writes will be positioned at the current end of file, + /// even when there are other processes or threads appending to the same file. This is + /// unlike [seek]\([SeekFrom]::[End]\(0)) followed by `write()`, which + /// has a race between seeking and writing during which another writer can write, with + /// our `write()` overwriting their data. + /// + /// Keep in mind that this does not necessarily guarantee that data appended by + /// different processes or threads does not interleave. The amount of data accepted a + /// single `write()` call depends on the operating system and file system. A + /// successful `write()` is allowed to write only part of the given data, so even if + /// you're careful to provide the whole message in a single call to `write()`, there + /// is no guarantee that it will be written out in full. If you rely on the filesystem + /// accepting the message in a single write, make sure that all data that belongs + /// together is written in one operation. This can be done by concatenating strings + /// before passing them to [`write()`]. + /// + /// If a file is opened with both read and append access, beware that after + /// opening, and after every write, the position for reading may be set at the + /// end of the file. So, before writing, save the current position (using + /// [Seek]::[stream_position]), and restore it before the next read. + /// + /// ## Note + /// + /// This function doesn't create the file if it doesn't exist. Use the + /// [`OpenOptions::create`] method to do so. + /// + /// [`write()`]: std::io::Write::write "io::Write::write" + /// [`flush()`]: std::io::Write::flush "io::Write::flush" + /// [Seek]: std::io::Seek "io::Seek" + /// [stream_position]: std::io::Seek::stream_position "io::Seek::stream_position" + /// [seek]: std::io::Seek::seek "io::Seek::seek" + /// [SeekFrom]: std::io::SeekFrom "io::SeekFrom" + /// [Current]: std::io::SeekFrom::Current "io::SeekFrom::Current" + /// [End]: std::io::SeekFrom::End "io::SeekFrom::End" + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.append(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn append(&mut self, append: bool) -> &mut Self { + self.append = append; + self + } + + /// Sets the option for truncating a previous file. + /// + /// If a file is successfully opened with this option set it will truncate + /// the file to 0 length if it already exists. + /// + /// The file must be opened with write access for truncate to work. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.write(true).truncate(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn truncate(&mut self, truncate: bool) -> &mut Self { + self.truncate = truncate; + self + } + + /// Sets the option to create a new file, or open it if it already exists. + /// + /// In order for the file to be created, [`OpenOptions::write`] or + /// [`OpenOptions::append`] access must be used. + /// + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.write(true).create(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn create(&mut self, create: bool) -> &mut Self { + self.create = create; + self + } + + /// Sets the option to create a new file, failing if it already exists. + /// + /// No file is allowed to exist at the target location, also no (dangling) symlink. In this + /// way, if the call succeeds, the file returned is guaranteed to be new. + /// If a file exists at the target location, creating a new file will fail with [`AlreadyExists`] + /// or another error based on the situation. See [`std::fs::OpenOptions::open`] for a + /// non-exhaustive list of likely errors. + /// + /// This option is useful because it is atomic. Otherwise between checking + /// whether a file exists and creating a new one, the file may have been + /// created by another process (a TOCTOU race condition / attack). + /// + /// If `.create_new(true)` is set, [`.create()`] and [`.truncate()`] are + /// ignored. + /// + /// The file must be opened with write or append access in order to create + /// a new file. + /// + /// [`.create()`]: OpenOptions::create + /// [`.truncate()`]: OpenOptions::truncate + /// [`AlreadyExists`]: std::io::ErrorKind::AlreadyExists + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::path::Path; + /// use tauri_plugin_fs::{FsExt, OpenOptions}; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// let mut options = OpenOptions::new(); + /// options.write(true).create_new(true); + /// let file = app.fs().open(Path::new("foo.txt"), options)?; + /// Ok(()) + /// }); + /// ``` + pub fn create_new(&mut self, create_new: bool) -> &mut Self { + self.create_new = create_new; + self + } +} + +#[cfg(unix)] +impl std::os::unix::fs::OpenOptionsExt for OpenOptions { + fn custom_flags(&mut self, flags: i32) -> &mut Self { + self.custom_flags.replace(flags); + self + } + + fn mode(&mut self, mode: u32) -> &mut Self { + self.mode.replace(mode); + self + } +} + +impl OpenOptions { + #[cfg(target_os = "android")] + fn android_mode(&self) -> String { + let mut mode = String::new(); + + if self.read { + mode.push('r'); + } + if self.write { + mode.push('w'); + } + if self.truncate { + mode.push('t'); + } + if self.append { + mode.push('a'); + } + + mode + } +} + +impl Fs { + /// Reads the entire contents of a file into a string. + /// + /// The file is opened in read-only mode with [`Fs::open`]. + /// + /// # Errors + /// + /// Returns an error if `path` cannot be opened for reading or if its + /// contents are not valid UTF-8. + pub fn read_to_string>(&self, path: P) -> std::io::Result { + let mut s = String::new(); + self.open( + path, + OpenOptions { + read: true, + ..Default::default() + }, + )? + .read_to_string(&mut s)?; + Ok(s) + } + + /// Reads the entire contents of a file into a bytes vector. + /// + /// The file is opened in read-only mode with [`Fs::open`]. + /// + /// # Errors + /// + /// Returns an error if `path` cannot be opened for reading. + pub fn read>(&self, path: P) -> std::io::Result> { + let mut buf = Vec::new(); + self.open( + path, + OpenOptions { + read: true, + ..Default::default() + }, + )? + .read_to_end(&mut buf)?; + Ok(buf) + } +} + +// implement ScopeObject here instead of in the scope module because it is also used on the build script +// and we don't want to add tauri as a build dependency +impl ScopeObject for scope::Entry { + type Error = Error; + fn deserialize( + app: &AppHandle, + raw: Value, + ) -> std::result::Result { + let path = serde_json::from_value(raw.into()).map(|raw| match raw { + scope::EntryRaw::Value(path) => path, + scope::EntryRaw::Object { path } => path, + })?; + + match app.path().parse(path) { + Ok(path) => Ok(Self { path: Some(path) }), + #[cfg(not(target_os = "android"))] + Err(tauri::Error::UnknownPath) => Ok(Self { path: None }), + Err(err) => Err(err.into()), + } + } +} + +pub(crate) struct Scope { + pub(crate) scope: tauri::fs::Scope, + pub(crate) require_literal_leading_dot: Option, +} + +/// Tracks which paths have active security-scoped resource access on iOS. +#[cfg(target_os = "ios")] +pub(crate) struct SecurityScopedResources { + /// Set of file URLs that are currently accessing security-scoped resources. + /// The key is the URL string representation. + pub(crate) active_urls: Mutex>, +} + +#[cfg(target_os = "ios")] +impl SecurityScopedResources { + pub(crate) fn new() -> Self { + Self { + active_urls: Mutex::new(std::collections::HashSet::new()), + } + } + + pub(crate) fn is_tracked_manually(&self, url: &str) -> bool { + self.active_urls.lock().unwrap().contains(url) + } + + pub(crate) fn track_manually(&self, url: String) { + self.active_urls.lock().unwrap().insert(url); + } + + pub(crate) fn remove(&self, url: &str) { + self.active_urls.lock().unwrap().remove(url); + } +} + +#[cfg(not(target_os = "ios"))] +pub(crate) struct SecurityScopedResources; + +#[cfg(not(target_os = "ios"))] +impl SecurityScopedResources { + pub(crate) fn new() -> Self { + Self + } + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn is_tracked_manually(&self, _url: &str) -> bool { + false + } + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn track_manually(&self, _url: String) {} + + #[allow(dead_code)] // Used on iOS, but not on other platforms + pub(crate) fn remove(&self, _url: &str) {} +} + +/// Extension trait implemented by every [`Manager`] (the app handle, windows, webviews, ...) +/// to access the file system plugin APIs. +/// +/// # Examples +/// +/// ```rust,no_run +/// use std::path::Path; +/// use tauri::Runtime; +/// use tauri_plugin_fs::FsExt; +/// +/// fn setup(app: &tauri::App) -> Result<(), Box> { +/// // allow the app to access a directory that is not part of the static scope +/// app.fs_scope().allow_directory(Path::new("/path/to/directory"), true)?; +/// +/// let contents = app.fs().read_to_string(Path::new("/path/to/directory/file.txt"))?; +/// println!("{contents}"); +/// +/// Ok(()) +/// } +/// ``` +pub trait FsExt { + /// Returns the file system scope, which can be used to dynamically + /// allow or deny paths at runtime. + /// + /// # Panics + /// + /// Panics if the plugin is not registered in the app. + /// Use [`FsExt::try_fs_scope`] if the plugin might not be registered. + fn fs_scope(&self) -> tauri::fs::Scope; + + /// Returns the file system scope, or `None` if the plugin is not registered in the app. + fn try_fs_scope(&self) -> Option; + + /// Cross platform file system APIs that also support manipulating Android files. + fn fs(&self) -> &Fs; +} + +impl> FsExt for T { + fn fs_scope(&self) -> tauri::fs::Scope { + self.state::().scope.clone() + } + + fn try_fs_scope(&self) -> Option { + self.try_state::().map(|s| s.scope.clone()) + } + + fn fs(&self) -> &Fs { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin> { + PluginBuilder::>::new("fs") + .invoke_handler(tauri::generate_handler![ + commands::create, + commands::open, + commands::copy_file, + commands::mkdir, + commands::read_dir, + commands::read, + commands::read_file, + commands::read_text_file, + commands::read_text_file_lines, + commands::read_text_file_lines_next, + commands::remove, + commands::rename, + commands::seek, + commands::stat, + commands::lstat, + commands::fstat, + commands::truncate, + commands::ftruncate, + commands::write, + commands::write_file, + commands::write_text_file, + commands::exists, + commands::size, + commands::start_accessing_security_scoped_resource, + commands::stop_accessing_security_scoped_resource, + #[cfg(feature = "watch")] + watcher::watch, + ]) + .setup(|app, api| { + let scope = Scope { + require_literal_leading_dot: api + .config() + .as_ref() + .and_then(|c| c.require_literal_leading_dot), + scope: tauri::fs::Scope::new(app, &FsScope::default())?, + }; + + #[cfg(target_os = "android")] + { + let fs = android::init(app, api)?; + app.manage(fs); + } + #[cfg(target_os = "ios")] + { + let fs = ios::init(app, api)?; + app.manage(fs); + } + #[cfg(desktop)] + app.manage(Fs(app.clone())); + + app.manage(scope); + app.manage(SecurityScopedResources::new()); + Ok(()) + }) + .on_event(|app, event| { + if let RunEvent::WindowEvent { + label: _, + event: WindowEvent::DragDrop(DragDropEvent::Drop { paths, position: _ }), + .. + } = event + { + let scope = app.fs_scope(); + for path in paths { + if path.is_file() { + let _ = scope.allow_file(path); + } else { + let _ = scope.allow_directory(path, true); + } + } + } + }) + .build() +} diff --git a/plugins/fs/src/models.rs b/plugins/fs/src/models.rs new file mode 100644 index 0000000..b9edc2c --- /dev/null +++ b/plugins/fs/src/models.rs @@ -0,0 +1,18 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetFileDescriptorPayload { + pub uri: String, + pub mode: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetFileDescriptorResponse { + pub fd: Option, +} diff --git a/plugins/fs/src/scope.rs b/plugins/fs/src/scope.rs new file mode 100644 index 0000000..7914706 --- /dev/null +++ b/plugins/fs/src/scope.rs @@ -0,0 +1,19 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug)] +pub struct Entry { + pub path: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum EntryRaw { + Value(PathBuf), + Object { path: PathBuf }, +} diff --git a/plugins/fs/src/watcher.rs b/plugins/fs/src/watcher.rs new file mode 100644 index 0000000..de9a85d --- /dev/null +++ b/plugins/fs/src/watcher.rs @@ -0,0 +1,104 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use notify_debouncer_full::{new_debouncer, DebouncedEvent, Debouncer, RecommendedCache}; +use serde::Deserialize; +use tauri::{ + ipc::{Channel, CommandScope, GlobalScope}, + path::BaseDirectory, + Manager, Resource, ResourceId, Runtime, Webview, +}; + +use std::time::Duration; + +use crate::{ + commands::{resolve_path, CommandResult}, + scope::Entry, + SafeFilePath, +}; + +#[allow(unused)] +enum WatcherKind { + Debouncer(Debouncer), + Watcher(RecommendedWatcher), +} + +impl Resource for WatcherKind {} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchOptions { + base_dir: Option, + #[serde(default)] + recursive: bool, + delay_ms: Option, +} + +#[tauri::command] +pub fn watch( + webview: Webview, + paths: Vec, + options: WatchOptions, + on_event: Channel, + global_scope: GlobalScope, + command_scope: CommandScope, +) -> CommandResult { + let resolved_paths = paths + .into_iter() + .map(|path| { + resolve_path( + "watch", + &webview, + &global_scope, + &command_scope, + path, + options.base_dir, + ) + }) + .collect::>>()?; + + let recursive_mode = if options.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + let watcher_kind = if let Some(delay) = options.delay_ms { + let mut debouncer = new_debouncer( + Duration::from_millis(delay), + None, + move |events: Result, Vec>| { + if let Ok(events) = events { + for event in events { + // TODO: Should errors be emitted too? + let _ = on_event.send(event.event); + } + } + }, + )?; + for path in &resolved_paths { + debouncer.watch(path, recursive_mode)?; + } + WatcherKind::Debouncer(debouncer) + } else { + let mut watcher = RecommendedWatcher::new( + move |event| { + if let Ok(event) = event { + // TODO: Should errors be emitted too? + let _ = on_event.send(event); + } + }, + Config::default(), + )?; + for path in &resolved_paths { + watcher.watch(path, recursive_mode)?; + } + WatcherKind::Watcher(watcher) + }; + + let rid = webview.resources_table().add(watcher_kind); + + Ok(rid) +} diff --git a/plugins/fs/tsconfig.json b/plugins/fs/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/fs/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/geolocation/CHANGELOG.md b/plugins/geolocation/CHANGELOG.md new file mode 100644 index 0000000..cbb3cef --- /dev/null +++ b/plugins/geolocation/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +## [2.3.3] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +## \[2.3.2] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.1] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. +- [`e933acb0`](https://github.com/tauri-apps/plugins-workspace/commit/e933acb0044b4d49053eae6492b542351160e66a) ([#3010](https://github.com/tauri-apps/plugins-workspace/pull/3010) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, use the `timeout` value for `setMinUpdateIntervalMillis`, `setMaxUpdateDelayMillis` and `setIntervalMillis` instead of just `minUpdateInterval`. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.5] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.4] + +- [`a1b3fa27`](https://github.com/tauri-apps/plugins-workspace/commit/a1b3fa27f11022c9b6622b4fab12d93239eb05de) ([#2515](https://github.com/tauri-apps/plugins-workspace/pull/2515) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Re-exported the `Geolocation`, `Haptics`, `Notification`, and `Os` structs so that they show up on docs.rs. + +## \[2.2.3] + +- [`406e6f48`](https://github.com/tauri-apps/plugins-workspace/commit/406e6f484cdc13d35c50fb949f7489ca9eeccc44) ([#2323](https://github.com/tauri-apps/plugins-workspace/pull/2323) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused build failures when the `haptics` or `geolocation` plugin was used without their `specta` feature flag enabled. + +## \[2.2.2] + +- [`c9c13a0f`](https://github.com/tauri-apps/plugins-workspace/commit/c9c13a0fe7cdaac223843f5ba33176252f8e22f5) ([#2316](https://github.com/tauri-apps/plugins-workspace/pull/2316) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) **Breaking change:** `specta` integration is now behind a `specta` feature flag like in Tauri. +- [`c9c13a0f`](https://github.com/tauri-apps/plugins-workspace/commit/c9c13a0fe7cdaac223843f5ba33176252f8e22f5) ([#2316](https://github.com/tauri-apps/plugins-workspace/pull/2316) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Unlock and widen `specta` version range to match Tauri. No API changes. + +## \[2.2.1] + +- [`fb67ab2b`](https://github.com/tauri-apps/plugins-workspace/commit/fb67ab2b926502bfc20d6b43fbdd156691ea8526) ([#2281](https://github.com/tauri-apps/plugins-workspace/pull/2281) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Added `specta-util` to fix a "dependency not found" compilation error. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.2] + +- [`60765694`](https://github.com/tauri-apps/plugins-workspace/commit/60765694f54875e22b8eb70b1d2e32dbf0c585c7) ([#1773](https://github.com/tauri-apps/plugins-workspace/pull/1773) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update API to match other plugins. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Use `PermissionState` from the `tauri` crate, which now also includes a "prompt with rationale" variant for Android (returned when your app must explain to the user why it needs the permission). +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +- [`5d170a54`](https://github.com/tauri-apps/plugins-workspace/commit/5d170a5444982dcc14135f6f1fc3e5da359f0eb0) ([#1671](https://github.com/tauri-apps/plugins-workspace/pull/1671) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.3. + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9606089b`](https://github.com/tauri-apps/plugins-workspace/commit/9606089b2add4a17f80ed5a09d59ce94824bd672) ([#1599](https://github.com/tauri-apps/plugins-workspace/pull/1599)) Initial release. +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. diff --git a/plugins/geolocation/Cargo.toml b/plugins/geolocation/Cargo.toml new file mode 100644 index 0000000..3c2a508 --- /dev/null +++ b/plugins/geolocation/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "tauri-plugin-geolocation" +description = "Get and track the device's current position" +version = "2.3.3" +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-geolocation" +exclude = ["/guest-js", "/package.json", "/rollup.config.js", "/tsconfig.json"] + +[package.metadata.docs.rs] +targets = ["x86_64-linux-android"] + +[package.metadata.platforms.support] +windows = { level = "none", notes = "" } +linux = { level = "none", notes = "" } +macos = { level = "none", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +specta = { workspace = true, optional = true } + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } + +[features] +specta = ["dep:specta", "tauri/specta"] diff --git a/plugins/geolocation/LICENSE.spdx b/plugins/geolocation/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/geolocation/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/geolocation/LICENSE_APACHE-2.0 b/plugins/geolocation/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/geolocation/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/geolocation/LICENSE_MIT b/plugins/geolocation/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/geolocation/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/geolocation/README.md b/plugins/geolocation/README.md new file mode 100644 index 0000000..3de41ef --- /dev/null +++ b/plugins/geolocation/README.md @@ -0,0 +1,173 @@ +![geolocation](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/geolocation/banner.png) + +This plugin provides APIs for getting and tracking the device's current position, including information about altitude, heading, and speed (if available). + +| Platform | Supported | +| -------- | --------- | +| Linux | x | +| Windows | x | +| macOS | x | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-geolocation = "2.0.0" +# alternatively with Git: +tauri-plugin-geolocation = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + + + +```sh +pnpm add @tauri-apps/plugin-geolocation +# or +npm add @tauri-apps/plugin-geolocation +# or +yarn add @tauri-apps/plugin-geolocation +``` + +## Setting up + +### iOS + +Apple requires privacy descriptions to be specified in `Info.plist` for location information: + +- `NSLocationWhenInUseDescription` + +### Android + +This plugin automatically adds the following permissions to your `AndroidManifest.xml` file: + +```xml + + +``` + +If your app requires GPS functionality to function, **you** should add the following to your `AndroidManifest.xml` file: + +```xml + +``` + +The Google Play Store uses this property to decide whether it should show the app to devices without GPS capabilities. + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_geolocation::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Then, for instance, grant the plugin the permission to check or request permissions from the user and to read the device position + +`src-tauri/capabilities/default.json` + +```json + "permissions": [ + "core:default", + "geolocation:allow-check-permissions", + "geolocation:allow-request-permissions", + "geolocation:allow-get-current-position", + "geolocation:allow-watch-position", + ] +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { + checkPermissions, + requestPermissions, + getCurrentPosition, + watchPosition +} from '@tauri-apps/plugin-geolocation' + +let permissions = await checkPermissions() +if ( + permissions.location === 'prompt' + || permissions.location === 'prompt-with-rationale' +) { + permissions = await requestPermissions(['location']) +} + +if (permissions.location === 'granted') { + const pos = await getCurrentPosition() + + await watchPosition( + { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, + (pos) => { + console.log(pos) + } + ) +} +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Contributed By + + + + + + + + +
+ + CrabNebula + + + + Rescue.co + +
+ +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/geolocation/SECURITY.md b/plugins/geolocation/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/geolocation/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/geolocation/android/.gitignore b/plugins/geolocation/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/geolocation/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/geolocation/android/build.gradle.kts b/plugins/geolocation/android/build.gradle.kts new file mode 100644 index 0000000..022f11d --- /dev/null +++ b/plugins/geolocation/android/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.geolocation" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.google.android.gms:play-services-location:21.3.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/geolocation/android/consumer-rules.pro b/plugins/geolocation/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/geolocation/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/geolocation/android/settings.gradle b/plugins/geolocation/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/geolocation/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/geolocation/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/geolocation/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..a301dc2 --- /dev/null +++ b/plugins/geolocation/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.geolocation + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("app.tauri.geolocation", appContext.packageName) + } +} diff --git a/plugins/geolocation/android/src/main/AndroidManifest.xml b/plugins/geolocation/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a47edd1 --- /dev/null +++ b/plugins/geolocation/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/geolocation/android/src/main/java/Geolocation.kt b/plugins/geolocation/android/src/main/java/Geolocation.kt new file mode 100644 index 0000000..f93d230 --- /dev/null +++ b/plugins/geolocation/android/src/main/java/Geolocation.kt @@ -0,0 +1,144 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.geolocation + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationManager +import android.os.SystemClock +import androidx.core.location.LocationManagerCompat +import app.tauri.Logger +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority + + +public class Geolocation(private val context: Context) { + private var fusedLocationClient: FusedLocationProviderClient? = null + private var locationCallback: LocationCallback? = null + + + fun isLocationServicesEnabled(): Boolean { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return LocationManagerCompat.isLocationEnabled(lm) + } + + @SuppressWarnings("MissingPermission") + fun sendLocation(enableHighAccuracy: Boolean, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) { + val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + if (resultCode == ConnectionResult.SUCCESS) { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + if (this.isLocationServicesEnabled()) { + var networkEnabled = false + + try { + networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (_: Exception) { + Logger.error("isProviderEnabled failed") + } + + val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER + val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio + + LocationServices + .getFusedLocationProviderClient(context) + .getCurrentLocation(prio, null) + .addOnFailureListener { e -> e.message?.let { errorCallback(it) } } + .addOnSuccessListener { location -> + if (location == null) { + errorCallback("Location unavailable.") + } else { + successCallback(location) + } + } + } else { + errorCallback("Location disabled.") + } + } else { + errorCallback("Google Play Services unavailable.") + } + } + + @SuppressLint("MissingPermission") + fun requestLocationUpdates(enableHighAccuracy: Boolean, timeout: Long, successCallback: (location: Location) -> Unit, errorCallback: (error: String) -> Unit) { + val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + if (resultCode == ConnectionResult.SUCCESS) { + clearLocationUpdates() + fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + if (this.isLocationServicesEnabled()) { + var networkEnabled = false + + try { + networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (_: Exception) { + Logger.error("isProviderEnabled failed") + } + + val lowPrio = if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER + val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio + + val locationRequest = LocationRequest.Builder(timeout) + .setMaxUpdateDelayMillis(timeout) + .setMinUpdateIntervalMillis(timeout) + .setPriority(prio) + .build() + + locationCallback = + object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + val lastLocation = locationResult.lastLocation + if (lastLocation == null) { + errorCallback("Location unavailable.") + } else { + successCallback(lastLocation) + } + } + } + + fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback!!, null) + } else { + errorCallback("Location disabled.") + } + } else { + errorCallback("Google Play Services not available.") + } + } + + fun clearLocationUpdates() { + if (locationCallback != null) { + fusedLocationClient?.removeLocationUpdates(locationCallback!!) + locationCallback = null + } + } + + @SuppressLint("MissingPermission") + fun getLastLocation(maximumAge: Long): Location? { + var lastLoc: Location? = null + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + for (provider in lm.allProviders) { + val tmpLoc = lm.getLastKnownLocation(provider) + if (tmpLoc != null) { + val locationAge = SystemClock.elapsedRealtimeNanos() - tmpLoc.elapsedRealtimeNanos + val maxAgeNano = maximumAge * 1000000L + if (locationAge <= maxAgeNano && (lastLoc == null || lastLoc.elapsedRealtimeNanos > tmpLoc.elapsedRealtimeNanos)) { + lastLoc = tmpLoc + } + } + } + + return lastLoc + } +} diff --git a/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt b/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt new file mode 100644 index 0000000..cf81f5e --- /dev/null +++ b/plugins/geolocation/android/src/main/java/GeolocationPlugin.kt @@ -0,0 +1,172 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.geolocation + +import android.Manifest +import android.app.Activity +import android.location.Location +import android.os.Build +import android.webkit.WebView +import app.tauri.Logger +import app.tauri.PermissionState +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.Permission +import app.tauri.annotation.PermissionCallback +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Channel +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +@InvokeArg +class PositionOptions { + var enableHighAccuracy: Boolean = false + var maximumAge: Long = 0 + var timeout: Long = 10000 +} + +@InvokeArg +class WatchArgs { + var options: PositionOptions = PositionOptions() + lateinit var channel: Channel +} + +@InvokeArg +class ClearWatchArgs { + var channelId: Long = 0 +} + +// TODO: Plugin does not ask user to enable google location services (like gmaps does) + +private const val ALIAS_LOCATION: String = "location" +private const val ALIAS_COARSE_LOCATION: String = "coarseLocation" + +@TauriPlugin( + permissions = [ + Permission(strings = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ], + alias = ALIAS_LOCATION + ), + Permission(strings = [ + Manifest.permission.ACCESS_COARSE_LOCATION + ], + alias = ALIAS_COARSE_LOCATION + ) + ] +) +class GeolocationPlugin(private val activity: Activity): Plugin(activity) { + private lateinit var implementation: Geolocation + private var watchers = hashMapOf>() + + override fun load(webView: WebView) { + super.load(webView) + implementation = Geolocation(activity.applicationContext) + } + + override fun onPause() { + super.onPause() + // Clear all location updates on pause to avoid possible background location calls + implementation.clearLocationUpdates() + } + + override fun onResume() { + super.onResume() + // resume watchers + for ((watcher, args) in watchers.values) { + startWatch(watcher, args) + } + } + + @Command + override fun checkPermissions(invoke: Invoke) { + if (implementation.isLocationServicesEnabled()) { + super.checkPermissions(invoke) + } else { + invoke.reject("Location services are disabled.") + } + } + + @Command + override fun requestPermissions(invoke: Invoke) { + if (implementation.isLocationServicesEnabled()) { + super.requestPermissions(invoke) + } else { + invoke.reject("Location services are disabled.") + } + } + + @Command + fun getCurrentPosition(invoke: Invoke) { + val args = invoke.parseArgs(PositionOptions::class.java) + + val location = implementation.getLastLocation(args.maximumAge) + if (location != null) { + invoke.resolve(convertLocation(location)) + } else { + implementation.sendLocation(args.enableHighAccuracy, + { loc -> invoke.resolve(convertLocation(loc)) }, + { error -> invoke.reject(error) }) + } + } + + @PermissionCallback + private fun positionPermissionCallback(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("location", getPermissionState(ALIAS_LOCATION)) + permissionsResultJSON.put("coarseLocation", getPermissionState(ALIAS_COARSE_LOCATION)) + invoke.resolve(permissionsResultJSON) + } + + @Command + fun watchPosition(invoke: Invoke) { + val args = invoke.parseArgs(WatchArgs::class.java) + startWatch(invoke, args) + } + + private fun startWatch(invoke: Invoke, args: WatchArgs) { + implementation.requestLocationUpdates( + args.options.enableHighAccuracy, + args.options.timeout, + { location -> args.channel.send(convertLocation(location)) }, + { error -> args.channel.sendObject(error) }) + + watchers[args.channel.id] = Pair(invoke, args) + } + + @Command + fun clearWatch(invoke: Invoke) { + val args = invoke.parseArgs(ClearWatchArgs::class.java) + + watchers.remove(args.channelId) + + if (watchers.isEmpty()) { + implementation.clearLocationUpdates() + } + + invoke.resolve() + } + + private fun convertLocation(location: Location): JSObject { + val ret = JSObject() + val coords = JSObject() + + coords.put("latitude", location.latitude) + coords.put("longitude", location.longitude) + coords.put("accuracy", location.accuracy) + coords.put("altitude", location.altitude) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + coords.put("altitudeAccuracy", location.verticalAccuracyMeters) + } + coords.put("speed", location.speed) + coords.put("heading", location.bearing) + ret.put("timestamp", location.time) + ret.put("coords", coords) + + return ret + } +} diff --git a/plugins/geolocation/android/src/test/java/ExampleUnitTest.kt b/plugins/geolocation/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..03c99f7 --- /dev/null +++ b/plugins/geolocation/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.geolocation + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/geolocation/api-iife.js b/plugins/geolocation/api-iife.js new file mode 100644 index 0000000..ef93840 --- /dev/null +++ b/plugins/geolocation/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_GEOLOCATION__=function(n,i){"use strict";return n.checkPermissions=async function(){return await i.checkPermissions("geolocation")},n.clearWatch=async function(n){await i.invoke("plugin:geolocation|clear_watch",{channelId:n})},n.getCurrentPosition=async function(n){return await i.invoke("plugin:geolocation|get_current_position",{options:n})},n.requestPermissions=async function(n){return await i.invoke("plugin:geolocation|request_permissions",{permissions:n})},n.watchPosition=async function(n,o){const e=new i.Channel;return e.onmessage=n=>{"string"==typeof n?o(null,n):o(n)},await i.invoke("plugin:geolocation|watch_position",{options:n,channel:e}),e.id},n}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"geolocation",{value:__TAURI_PLUGIN_GEOLOCATION__})} diff --git a/plugins/geolocation/build.rs b/plugins/geolocation/build.rs new file mode 100644 index 0000000..b524976 --- /dev/null +++ b/plugins/geolocation/build.rs @@ -0,0 +1,24 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &[ + "get_current_position", + "watch_position", + "clear_watch", + "check_permissions", + "request_permissions", +]; + +fn main() { + let result = tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .try_build(); + + // when building documentation for Android the plugin build result is always Err() and is irrelevant to the crate documentation build + if !(cfg!(docsrs) && std::env::var("TARGET").unwrap().contains("android")) { + result.unwrap(); + } +} diff --git a/plugins/geolocation/contributors/crabnebula.svg b/plugins/geolocation/contributors/crabnebula.svg new file mode 100644 index 0000000..40e2413 --- /dev/null +++ b/plugins/geolocation/contributors/crabnebula.svg @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/plugins/geolocation/contributors/rescue.png b/plugins/geolocation/contributors/rescue.png new file mode 100644 index 0000000..2b5916f Binary files /dev/null and b/plugins/geolocation/contributors/rescue.png differ diff --git a/plugins/geolocation/guest-js/index.ts b/plugins/geolocation/guest-js/index.ts new file mode 100644 index 0000000..2c05bda --- /dev/null +++ b/plugins/geolocation/guest-js/index.ts @@ -0,0 +1,239 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Get and track the device's current position, mirroring the W3C Geolocation API. + * + * @module + */ + +import { + Channel, + invoke, + PermissionState, + checkPermissions as checkPluginPermissions +} from '@tauri-apps/api/core' + +/** + * The GPS coordinates of a {@link Position}, along with the accuracy of each reading. + */ +export type Coordinates = { + /** + * Latitude in decimal degrees. + */ + latitude: number + /** + * Longitude in decimal degrees. + */ + longitude: number + /** + * Accuracy level of the latitude and longitude coordinates in meters. + */ + accuracy: number + /** + * Accuracy level of the altitude coordinate in meters, if available. + * Available on all iOS versions and on Android 8 and above. + */ + altitudeAccuracy: number | null + /** + * The altitude the user is at, if available. + */ + altitude: number | null + /** + * The speed the user is traveling, in meters per second, if available. + */ + speed: number | null + /** + * The heading the user is facing, if available. + */ + heading: number | null +} + +/** + * The current permission state for the geolocation APIs. + */ +export type PermissionStatus = { + /** + * Permission state for the location alias. + * + * On Android it requests/checks both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION permissions. + * + * On iOS it requests/checks location permissions. + */ + location: PermissionState + /** + * Permissions state for the coarseLoaction alias. + * + * On Android it requests/checks ACCESS_COARSE_LOCATION. + * + * On Android 12+, users can choose between Approximate location (ACCESS_COARSE_LOCATION) and Precise location (ACCESS_FINE_LOCATION). + * + * On iOS it will have the same value as the `location` alias. + */ + coarseLocation: PermissionState +} + +/** + * The individual permission aliases that can be requested with {@link requestPermissions}. + * + * `location` maps to both the coarse and fine location permissions on Android and to the standard + * location permission on iOS. `coarseLocation` maps to the coarse location permission only on + * Android, and behaves the same as `location` on iOS. + */ +export type PermissionType = 'location' | 'coarseLocation' + +/** + * A geolocation reading, as returned by {@link getCurrentPosition} and passed to the callback of {@link watchPosition}. + */ +export type Position = { + /** + * Creation time for these coordinates, in milliseconds since the Unix epoch. + */ + timestamp: number + /** + * The GPS coordinates along with the accuracy of the data. + */ + coords: Coordinates +} + +/** + * Options used to configure a {@link getCurrentPosition} or {@link watchPosition} request. + */ +export type PositionOptions = { + /** + * High accuracy mode (such as GPS, if available) + * Will be ignored on Android 12+ if users didn't grant the ACCESS_FINE_LOCATION permission (`coarseLocation` permission). + */ + enableHighAccuracy: boolean + /** + * The maximum wait time in milliseconds for location updates. + * On Android the timeout gets ignored for getCurrentPosition. + * Ignored on iOS + */ + timeout: number + /** + * The maximum age in milliseconds of a possible cached position that is acceptable to return. + * Default: 0 + * Ignored on iOS + */ + maximumAge: number +} + +/** + * Registers a callback that is invoked with the device's position whenever it changes, similar to the W3C `navigator.geolocation.watchPosition` API. Pass the returned id to {@link clearWatch} to stop watching. + * + * @example + * ```typescript + * import { watchPosition } from '@tauri-apps/plugin-geolocation'; + * const watchId = await watchPosition( + * { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, + * (position, error) => { + * if (error) { + * console.error(error) + * } else { + * console.log(position) + * } + * } + * ); + * ``` + * + * @param options Configuration for the position watcher. + * @param cb Callback invoked with the new {@link Position} on success, or `null` and an error message when a read fails. + * @returns A promise resolving to the id of the registered watcher. + * @since 2.0.0 + */ +export async function watchPosition( + options: PositionOptions, + cb: (location: Position | null, error?: string) => void +): Promise { + const channel = new Channel() + channel.onmessage = (message) => { + if (typeof message === 'string') { + cb(null, message) + } else { + cb(message) + } + } + await invoke('plugin:geolocation|watch_position', { + options, + channel + }) + return channel.id +} + +/** + * Returns the device's current position, similar to the W3C `navigator.geolocation.getCurrentPosition` API. + * + * @example + * ```typescript + * import { getCurrentPosition } from '@tauri-apps/plugin-geolocation'; + * const position = await getCurrentPosition(); + * ``` + * + * @param options Configuration for the position request. + * @returns A promise resolving to the current {@link Position}. + * @since 2.0.0 + */ +export async function getCurrentPosition( + options?: PositionOptions +): Promise { + return await invoke('plugin:geolocation|get_current_position', { + options + }) +} + +/** + * Stops the position watcher registered with {@link watchPosition}. + * + * @example + * ```typescript + * import { clearWatch } from '@tauri-apps/plugin-geolocation'; + * await clearWatch(watchId); + * ``` + * + * @param channelId The id returned by {@link watchPosition}. + * @since 2.0.0 + */ +export async function clearWatch(channelId: number): Promise { + await invoke('plugin:geolocation|clear_watch', { + channelId + }) +} + +/** + * Returns the current state of the geolocation permissions. Rejects if location services are disabled on the device. + * + * @example + * ```typescript + * import { checkPermissions } from '@tauri-apps/plugin-geolocation'; + * const permission = await checkPermissions(); + * ``` + * + * @returns A promise resolving to the current {@link PermissionStatus}. + * @since 2.0.0 + */ +export async function checkPermissions(): Promise { + return await checkPluginPermissions('geolocation') +} + +/** + * Requests the given geolocation permissions, prompting the user if needed. Rejects if location services are disabled on the device. + * + * @example + * ```typescript + * import { requestPermissions } from '@tauri-apps/plugin-geolocation'; + * const permission = await requestPermissions(['location']); + * ``` + * + * @param permissions The permissions to request, or `null` to request all of them. + * @returns A promise resolving to the resulting {@link PermissionStatus}. + * @since 2.0.0 + */ +export async function requestPermissions( + permissions: PermissionType[] | null +): Promise { + return await invoke('plugin:geolocation|request_permissions', { + permissions + }) +} diff --git a/plugins/geolocation/ios/.gitignore b/plugins/geolocation/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/geolocation/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/geolocation/ios/Package.swift b/plugins/geolocation/ios/Package.swift new file mode 100644 index 0000000..f8cfb73 --- /dev/null +++ b/plugins/geolocation/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-geolocation", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-geolocation", + type: .static, + targets: ["tauri-plugin-geolocation"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-geolocation", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/geolocation/ios/README.md b/plugins/geolocation/ios/README.md new file mode 100644 index 0000000..5612ac8 --- /dev/null +++ b/plugins/geolocation/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin Geolocation + +A description of this package. diff --git a/plugins/geolocation/ios/Sources/GeolocationPlugin.swift b/plugins/geolocation/ios/Sources/GeolocationPlugin.swift new file mode 100644 index 0000000..7a2b57a --- /dev/null +++ b/plugins/geolocation/ios/Sources/GeolocationPlugin.swift @@ -0,0 +1,250 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import CoreLocation +import SwiftRs +import Tauri +import UIKit +import WebKit + +class GetPositionArgs: Decodable { + var enableHighAccuracy: Bool? +} + +class WatchPositionArgs: Decodable { + let options: GetPositionArgs + let channel: Channel +} + +class ClearWatchArgs: Decodable { + let channelId: UInt32 +} + +class GeolocationPlugin: Plugin, CLLocationManagerDelegate { + private let locationManager = CLLocationManager() + private var isUpdatingLocation: Bool = false + private var permissionRequests: [Invoke] = [] + private var positionRequests: [Invoke] = [] + private var watcherChannels: [Channel] = [] + + override init() { + super.init() + locationManager.delegate = self + } + + // + // Tauri commands + // + + @objc public func getCurrentPosition(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(GetPositionArgs.self) + + self.positionRequests.append(invoke) + + DispatchQueue.main.async { + if args.enableHighAccuracy == true { + self.locationManager.desiredAccuracy = kCLLocationAccuracyBest + } else { + self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer + } + + // TODO: Use the authorizationStatus instance property with locationManagerDidChangeAuthorization(_:) instead. + if CLLocationManager.authorizationStatus() == .notDetermined { + self.locationManager.requestWhenInUseAuthorization() + } else { + self.locationManager.requestLocation() + } + } + } + + @objc public func watchPosition(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(WatchPositionArgs.self) + + self.watcherChannels.append(args.channel) + + DispatchQueue.main.async { + if args.options.enableHighAccuracy == true { + self.locationManager.desiredAccuracy = kCLLocationAccuracyBest + } else { + self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer + } + + // TODO: Use the authorizationStatus instance property with locationManagerDidChangeAuthorization(_:) instead. + if CLLocationManager.authorizationStatus() == .notDetermined { + self.locationManager.requestWhenInUseAuthorization() + } else { + self.locationManager.startUpdatingLocation() + self.isUpdatingLocation = true + } + } + + invoke.resolve() + } + + @objc public func clearWatch(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(ClearWatchArgs.self) + + self.watcherChannels = self.watcherChannels.filter { $0.id != args.channelId } + + // TODO: capacitor plugin calls stopUpdating unconditionally + if self.watcherChannels.isEmpty { + self.stopUpdating() + } + + invoke.resolve() + } + + @objc override public func checkPermissions(_ invoke: Invoke) { + var status: String = "" + + if CLLocationManager.locationServicesEnabled() { + // TODO: Use the authorizationStatus instance property with locationManagerDidChangeAuthorization(_:) instead. + switch CLLocationManager.authorizationStatus() { + case .notDetermined: + status = "prompt" + case .restricted, .denied: + status = "denied" + case .authorizedAlways, .authorizedWhenInUse: + status = "granted" + @unknown default: + status = "prompt" + } + } else { + invoke.reject("Location services are not enabled.") + return + } + + let result = ["location": status, "coarseLocation": status] + + invoke.resolve(result) + } + + @objc override public func requestPermissions(_ invoke: Invoke) { + if CLLocationManager.locationServicesEnabled() { + // TODO: Use the authorizationStatus instance property with locationManagerDidChangeAuthorization(_:) instead. + if CLLocationManager.authorizationStatus() == .notDetermined { + self.permissionRequests.append(invoke) + + DispatchQueue.main.async { + self.locationManager.requestWhenInUseAuthorization() + } + } else { + checkPermissions(invoke) + } + } else { + invoke.reject("Location services are not enabled.") + } + } + + // + // Delegate methods + // + + public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Logger.error(error) + + let requests = self.positionRequests + self.permissionRequests + self.positionRequests.removeAll() + self.permissionRequests.removeAll() + + for request in requests { + request.reject(error.localizedDescription) + } + + for channel in self.watcherChannels { + do { + try channel.send(error.localizedDescription) + } catch { + Logger.error(error) + } + } + } + + public func locationManager( + _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation] + ) { + // Respond to all getCurrentPosition() calls. + for request in self.positionRequests { + // The capacitor plugin uses locations.first but .last should be the most current one + // and i don't see a reason to use old locations + if let location = locations.last { + let result = convertLocation(location) + request.resolve(result) + } else { + request.reject("Location service returned an empty Location array.") + } + } + + for channel in self.watcherChannels { + // The capacitor plugin uses locations.first but .last should be the most recent one + // and i don't see a reason to use old locations + if let location = locations.last { + let result = convertLocation(location) + do { + try channel.send(result) + } catch { + Logger.error(error) + } + } else { + do { + try channel.send("Location service returned an empty Location array.") + } catch { + Logger.error(error) + } + } + } + } + + public func locationManager( + _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus + ) { + let requests = self.permissionRequests + self.permissionRequests.removeAll() + + for request in requests { + checkPermissions(request) + } + + if !self.positionRequests.isEmpty { + self.locationManager.requestLocation() + } + + if !self.watcherChannels.isEmpty && !self.isUpdatingLocation { + self.locationManager.startUpdatingLocation() + self.isUpdatingLocation = true + } + } + + // + // Internal/Helper methods + // + + // TODO: Why is this pub in capacitor + private func stopUpdating() { + self.locationManager.stopUpdatingLocation() + self.isUpdatingLocation = false + } + + private func convertLocation(_ location: CLLocation) -> JsonObject { + var ret: JsonObject = [:] + var coords: JsonObject = [:] + + coords["latitude"] = location.coordinate.latitude + coords["longitude"] = location.coordinate.longitude + coords["accuracy"] = location.horizontalAccuracy + coords["altitude"] = location.altitude + coords["altitudeAccuracy"] = location.verticalAccuracy + coords["speed"] = location.speed + coords["heading"] = location.course + ret["timestamp"] = Int((location.timestamp.timeIntervalSince1970 * 1000)) + ret["coords"] = coords + + return ret + } +} + +@_cdecl("init_plugin_geolocation") +func initPlugin() -> Plugin { + return GeolocationPlugin() +} diff --git a/plugins/geolocation/ios/Tests/PluginTests/PluginTests.swift b/plugins/geolocation/ios/Tests/PluginTests/PluginTests.swift new file mode 100644 index 0000000..99992ce --- /dev/null +++ b/plugins/geolocation/ios/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import XCTest +@testable import ExamplePlugin + +final class ExamplePluginTests: XCTestCase { + func testExample() throws { + let plugin = ExamplePlugin() + } +} diff --git a/plugins/geolocation/package.json b/plugins/geolocation/package.json new file mode 100644 index 0000000..32bbb0e --- /dev/null +++ b/plugins/geolocation/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-geolocation", + "version": "2.3.3", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/geolocation/permissions/autogenerated/commands/check_permissions.toml b/plugins/geolocation/permissions/autogenerated/commands/check_permissions.toml new file mode 100644 index 0000000..f5af08b --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/check_permissions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-check-permissions" +description = "Enables the check_permissions command without any pre-configured scope." +commands.allow = ["check_permissions"] + +[[permission]] +identifier = "deny-check-permissions" +description = "Denies the check_permissions command without any pre-configured scope." +commands.deny = ["check_permissions"] diff --git a/plugins/geolocation/permissions/autogenerated/commands/clear_permissions.toml b/plugins/geolocation/permissions/autogenerated/commands/clear_permissions.toml new file mode 100644 index 0000000..a3e6ab5 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/clear_permissions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-clear-permissions" +description = "Enables the clear_permissions command without any pre-configured scope." +commands.allow = ["clear_permissions"] + +[[permission]] +identifier = "deny-clear-permissions" +description = "Denies the clear_permissions command without any pre-configured scope." +commands.deny = ["clear_permissions"] diff --git a/plugins/geolocation/permissions/autogenerated/commands/clear_watch.toml b/plugins/geolocation/permissions/autogenerated/commands/clear_watch.toml new file mode 100644 index 0000000..19fb577 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/clear_watch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-clear-watch" +description = "Enables the clear_watch command without any pre-configured scope." +commands.allow = ["clear_watch"] + +[[permission]] +identifier = "deny-clear-watch" +description = "Denies the clear_watch command without any pre-configured scope." +commands.deny = ["clear_watch"] diff --git a/plugins/geolocation/permissions/autogenerated/commands/get_current_position.toml b/plugins/geolocation/permissions/autogenerated/commands/get_current_position.toml new file mode 100644 index 0000000..fefb951 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/get_current_position.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-current-position" +description = "Enables the get_current_position command without any pre-configured scope." +commands.allow = ["get_current_position"] + +[[permission]] +identifier = "deny-get-current-position" +description = "Denies the get_current_position command without any pre-configured scope." +commands.deny = ["get_current_position"] diff --git a/plugins/geolocation/permissions/autogenerated/commands/request_permissions.toml b/plugins/geolocation/permissions/autogenerated/commands/request_permissions.toml new file mode 100644 index 0000000..02dcd62 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/request_permissions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-request-permissions" +description = "Enables the request_permissions command without any pre-configured scope." +commands.allow = ["request_permissions"] + +[[permission]] +identifier = "deny-request-permissions" +description = "Denies the request_permissions command without any pre-configured scope." +commands.deny = ["request_permissions"] diff --git a/plugins/geolocation/permissions/autogenerated/commands/watch_position.toml b/plugins/geolocation/permissions/autogenerated/commands/watch_position.toml new file mode 100644 index 0000000..5878ba1 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/commands/watch_position.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-watch-position" +description = "Enables the watch_position command without any pre-configured scope." +commands.allow = ["watch_position"] + +[[permission]] +identifier = "deny-watch-position" +description = "Denies the watch_position command without any pre-configured scope." +commands.deny = ["watch_position"] diff --git a/plugins/geolocation/permissions/autogenerated/reference.md b/plugins/geolocation/permissions/autogenerated/reference.md new file mode 100644 index 0000000..0215a99 --- /dev/null +++ b/plugins/geolocation/permissions/autogenerated/reference.md @@ -0,0 +1,165 @@ +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`geolocation:allow-check-permissions` + + + +Enables the check_permissions command without any pre-configured scope. + +
+ +`geolocation:deny-check-permissions` + + + +Denies the check_permissions command without any pre-configured scope. + +
+ +`geolocation:allow-clear-permissions` + + + +Enables the clear_permissions command without any pre-configured scope. + +
+ +`geolocation:deny-clear-permissions` + + + +Denies the clear_permissions command without any pre-configured scope. + +
+ +`geolocation:allow-clear-watch` + + + +Enables the clear_watch command without any pre-configured scope. + +
+ +`geolocation:deny-clear-watch` + + + +Denies the clear_watch command without any pre-configured scope. + +
+ +`geolocation:allow-get-current-position` + + + +Enables the get_current_position command without any pre-configured scope. + +
+ +`geolocation:deny-get-current-position` + + + +Denies the get_current_position command without any pre-configured scope. + +
+ +`geolocation:allow-request-permissions` + + + +Enables the request_permissions command without any pre-configured scope. + +
+ +`geolocation:deny-request-permissions` + + + +Denies the request_permissions command without any pre-configured scope. + +
+ +`geolocation:allow-watch-position` + + + +Enables the watch_position command without any pre-configured scope. + +
+ +`geolocation:deny-watch-position` + + + +Denies the watch_position command without any pre-configured scope. + +
diff --git a/plugins/geolocation/permissions/schemas/schema.json b/plugins/geolocation/permissions/schemas/schema.json new file mode 100644 index 0000000..237b445 --- /dev/null +++ b/plugins/geolocation/permissions/schemas/schema.json @@ -0,0 +1,372 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-check-permissions", + "markdownDescription": "Enables the check_permissions command without any pre-configured scope." + }, + { + "description": "Denies the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-check-permissions", + "markdownDescription": "Denies the check_permissions command without any pre-configured scope." + }, + { + "description": "Enables the clear_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-clear-permissions", + "markdownDescription": "Enables the clear_permissions command without any pre-configured scope." + }, + { + "description": "Denies the clear_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-clear-permissions", + "markdownDescription": "Denies the clear_permissions command without any pre-configured scope." + }, + { + "description": "Enables the clear_watch command without any pre-configured scope.", + "type": "string", + "const": "allow-clear-watch", + "markdownDescription": "Enables the clear_watch command without any pre-configured scope." + }, + { + "description": "Denies the clear_watch command without any pre-configured scope.", + "type": "string", + "const": "deny-clear-watch", + "markdownDescription": "Denies the clear_watch command without any pre-configured scope." + }, + { + "description": "Enables the get_current_position command without any pre-configured scope.", + "type": "string", + "const": "allow-get-current-position", + "markdownDescription": "Enables the get_current_position command without any pre-configured scope." + }, + { + "description": "Denies the get_current_position command without any pre-configured scope.", + "type": "string", + "const": "deny-get-current-position", + "markdownDescription": "Denies the get_current_position command without any pre-configured scope." + }, + { + "description": "Enables the request_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-request-permissions", + "markdownDescription": "Enables the request_permissions command without any pre-configured scope." + }, + { + "description": "Denies the request_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-request-permissions", + "markdownDescription": "Denies the request_permissions command without any pre-configured scope." + }, + { + "description": "Enables the watch_position command without any pre-configured scope.", + "type": "string", + "const": "allow-watch-position", + "markdownDescription": "Enables the watch_position command without any pre-configured scope." + }, + { + "description": "Denies the watch_position command without any pre-configured scope.", + "type": "string", + "const": "deny-watch-position", + "markdownDescription": "Denies the watch_position command without any pre-configured scope." + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/geolocation/rollup.config.js b/plugins/geolocation/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/geolocation/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/geolocation/src/commands.rs b/plugins/geolocation/src/commands.rs new file mode 100644 index 0000000..d2cae84 --- /dev/null +++ b/plugins/geolocation/src/commands.rs @@ -0,0 +1,42 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{command, ipc::Channel, AppHandle, Runtime}; + +use crate::{GeolocationExt, PermissionStatus, PermissionType, Position, PositionOptions, Result}; + +#[command] +pub(crate) async fn get_current_position( + app: AppHandle, + options: Option, +) -> Result { + app.geolocation().get_current_position(options) +} + +#[command] +pub(crate) async fn watch_position( + app: AppHandle, + options: PositionOptions, + channel: Channel, +) -> Result<()> { + app.geolocation().watch_position_inner(options, channel) +} + +#[command] +pub(crate) async fn clear_watch(app: AppHandle, channel_id: u32) -> Result<()> { + app.geolocation().clear_watch(channel_id) +} + +#[command] +pub(crate) async fn check_permissions(app: AppHandle) -> Result { + app.geolocation().check_permissions() +} + +#[command] +pub(crate) async fn request_permissions( + app: AppHandle, + permissions: Option>, +) -> Result { + app.geolocation().request_permissions(permissions) +} diff --git a/plugins/geolocation/src/desktop.rs b/plugins/geolocation/src/desktop.rs new file mode 100644 index 0000000..3ca41db --- /dev/null +++ b/plugins/geolocation/src/desktop.rs @@ -0,0 +1,100 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Serialize}; +use tauri::{ + ipc::{Channel, InvokeResponseBody}, + plugin::PluginApi, + AppHandle, Runtime, +}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Geolocation(app.clone())) +} + +/// Access to the geolocation APIs. +pub struct Geolocation(AppHandle); + +impl Geolocation { + /// Not implemented on desktop platforms; always resolves to a default, zeroed [`Position`] without reading any real location. + pub fn get_current_position( + &self, + _options: Option, + ) -> crate::Result { + Ok(Position::default()) + } + + /// Not implemented on desktop platforms. Registers a channel for `channel_id` bookkeeping, but `callback` is never invoked with a real [`WatchEvent`]. + pub fn watch_position( + &self, + options: PositionOptions, + callback: F, + ) -> crate::Result { + let channel = Channel::new(move |event| { + let payload = match event { + InvokeResponseBody::Json(payload) => serde_json::from_str::(&payload) + .unwrap_or_else(|error| { + WatchEvent::Error(format!( + "Couldn't deserialize watch event payload: `{error}`" + )) + }), + _ => WatchEvent::Error("Unexpected watch event payload.".to_string()), + }; + + callback(payload); + + Ok(()) + }); + let id = channel.id(); + + self.watch_position_inner(options, channel)?; + + Ok(id) + } + + pub(crate) fn watch_position_inner( + &self, + _options: PositionOptions, + _callback_channel: Channel, + ) -> crate::Result<()> { + Ok(()) + } + + /// Not implemented on desktop platforms; always succeeds without doing anything. + pub fn clear_watch(&self, _channel_id: u32) -> crate::Result<()> { + Ok(()) + } + + /// Not implemented on desktop platforms; always resolves to the default [`PermissionStatus`] (both permissions in the [`Prompt`](tauri::plugin::PermissionState::Prompt) state). + pub fn check_permissions(&self) -> crate::Result { + Ok(PermissionStatus::default()) + } + + /// Not implemented on desktop platforms; always resolves to the default [`PermissionStatus`] without prompting the user. + pub fn request_permissions( + &self, + _permissions: Option>, + ) -> crate::Result { + Ok(PermissionStatus::default()) + } +} + +#[derive(Serialize)] +#[allow(unused)] // TODO: +struct WatchPayload { + options: PositionOptions, + channel: Channel, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(unused)] // TODO: +struct ClearWatchPayload { + channel_id: u32, +} diff --git a/plugins/geolocation/src/error.rs b/plugins/geolocation/src/error.rs new file mode 100644 index 0000000..1c4f695 --- /dev/null +++ b/plugins/geolocation/src/error.rs @@ -0,0 +1,33 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for the result type returned by the geolocation APIs. +pub type Result = std::result::Result; + +// TODO: Improve Error handling (different typed errors instead of one (stringified) PluginInvokeError for all mobile errors) + +/// Errors that can occur while interacting with the geolocation APIs. +#[derive(Debug, thiserror::Error)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub enum Error { + /// Forwards an error returned by the underlying Android or iOS plugin invocation. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke( + #[cfg_attr(feature = "specta", specta(skip))] + #[from] + tauri::plugin::mobile::PluginInvokeError, + ), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/geolocation/src/lib.rs b/plugins/geolocation/src/lib.rs new file mode 100644 index 0000000..2c87c13 --- /dev/null +++ b/plugins/geolocation/src/lib.rs @@ -0,0 +1,65 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Access device geolocation APIs. +//! +//! ## Cargo features +//! +//! - **specta**: Add support for [`specta::specta`](https://docs.rs/specta/2.0.0-rc.25/specta/attr.specta.html) on structs like [`PermissionStatus`], [`PositionOptions`]. + +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +pub use desktop::Geolocation; +#[cfg(mobile)] +pub use mobile::Geolocation; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the geolocation APIs. +pub trait GeolocationExt { + /// Returns the handle to the geolocation APIs. + fn geolocation(&self) -> &Geolocation; +} + +impl> crate::GeolocationExt for T { + fn geolocation(&self) -> &Geolocation { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("geolocation") + .invoke_handler(tauri::generate_handler![ + commands::get_current_position, + commands::watch_position, + commands::clear_watch, + commands::check_permissions, + commands::request_permissions + ]) + .setup(|app, api| { + #[cfg(mobile)] + let geolocation = mobile::init(app, api)?; + #[cfg(desktop)] + let geolocation = desktop::init(app, api)?; + app.manage(geolocation); + Ok(()) + }) + .build() +} diff --git a/plugins/geolocation/src/mobile.rs b/plugins/geolocation/src/mobile.rs new file mode 100644 index 0000000..4de9676 --- /dev/null +++ b/plugins/geolocation/src/mobile.rs @@ -0,0 +1,123 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Serialize}; +use tauri::{ + ipc::{Channel, InvokeResponseBody}, + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.geolocation"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_geolocation); + +// initializes the Kotlin or Swift plugin classes +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "GeolocationPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_geolocation)?; + Ok(Geolocation(handle)) +} + +/// Access to the geolocation APIs. +pub struct Geolocation(PluginHandle); + +impl Geolocation { + /// Returns the device's current [`Position`]. On Android this returns the last known location immediately if it is still within `maximum_age`, otherwise it requests a fresh reading. Errors if location services are disabled or the required permission was not granted. + pub fn get_current_position( + &self, + options: Option, + ) -> crate::Result { + // TODO: We may have to send over None if that's better on Android + self.0 + .run_mobile_plugin("getCurrentPosition", options.unwrap_or_default()) + .map_err(Into::into) + } + + /// Register a position watcher. This method returns an id to use in `clear_watch`. + pub fn watch_position( + &self, + options: PositionOptions, + callback: F, + ) -> crate::Result { + let channel = Channel::new(move |event| { + let payload = match event { + InvokeResponseBody::Json(payload) => serde_json::from_str::(&payload) + .unwrap_or_else(|error| { + WatchEvent::Error(format!( + "Couldn't deserialize watch event payload: `{error}`" + )) + }), + _ => WatchEvent::Error("Unexpected watch event payload.".to_string()), + }; + + callback(payload); + + Ok(()) + }); + let id = channel.id(); + + self.watch_position_inner(options, channel)?; + + Ok(id) + } + + pub(crate) fn watch_position_inner( + &self, + options: PositionOptions, + channel: Channel, + ) -> crate::Result<()> { + self.0 + .run_mobile_plugin("watchPosition", WatchPayload { options, channel }) + .map_err(Into::into) + } + + /// Removes the position watcher registered with the given `channel_id`, as returned by [`Self::watch_position`]. Stops the platform location updates once no watcher remains. + pub fn clear_watch(&self, channel_id: u32) -> crate::Result<()> { + self.0 + .run_mobile_plugin("clearWatch", ClearWatchPayload { channel_id }) + .map_err(Into::into) + } + + /// Returns the current [`PermissionStatus`] for the geolocation APIs. Errors if location services are disabled on the device. + pub fn check_permissions(&self) -> crate::Result { + self.0 + .run_mobile_plugin("checkPermissions", ()) + .map_err(Into::into) + } + + /// Requests the given `permissions` (or all of them when `None`) and returns the resulting [`PermissionStatus`]. Errors if location services are disabled on the device. + pub fn request_permissions( + &self, + permissions: Option>, + ) -> crate::Result { + self.0 + .run_mobile_plugin( + "requestPermissions", + serde_json::json!({ "permissions": permissions }), + ) + .map_err(Into::into) + } +} + +#[derive(Serialize)] +struct WatchPayload { + options: PositionOptions, + channel: Channel, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ClearWatchPayload { + channel_id: u32, +} diff --git a/plugins/geolocation/src/models.rs b/plugins/geolocation/src/models.rs new file mode 100644 index 0000000..9dd7d3a --- /dev/null +++ b/plugins/geolocation/src/models.rs @@ -0,0 +1,106 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize}; +use tauri::plugin::PermissionState; + +/// The current permission state for the geolocation APIs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct PermissionStatus { + /// Permission state for the location alias. + /// + /// On Android it requests/checks both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION permissions. + /// + /// On iOS it requests/checks location permissions. + pub location: PermissionState, + /// Permissions state for the coarseLoaction alias. + /// + /// On Android it requests/checks ACCESS_COARSE_LOCATION. + /// + /// On Android 12+, users can choose between Approximate location (ACCESS_COARSE_LOCATION) and Precise location (ACCESS_FINE_LOCATION). + /// + /// On iOS it will have the same value as the `location` alias. + pub coarse_location: PermissionState, +} + +/// Options used to configure a [`get_current_position`](crate::Geolocation::get_current_position) or [`watch_position`](crate::Geolocation::watch_position) request. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct PositionOptions { + /// High accuracy mode (such as GPS, if available) + /// Will be ignored on Android 12+ if users didn't grant the ACCESS_FINE_LOCATION permission. + pub enable_high_accuracy: bool, + /// The maximum wait time in milliseconds for location updates. + /// Default: 10000 + /// On Android the timeout gets ignored for getCurrentPosition. + /// Ignored on iOS. + // TODO: Handle Infinity and default to it. + // TODO: Should be u64+ but specta doesn't like that? + pub timeout: u32, + /// The maximum age in milliseconds of a possible cached position that is acceptable to return. + /// Default: 0 + /// Ignored on iOS. + // TODO: Handle Infinity. + // TODO: Should be u64+ but specta doesn't like that? + pub maximum_age: u32, +} + +/// The individual permission aliases that can be requested with [`request_permissions`](crate::Geolocation::request_permissions). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub enum PermissionType { + /// The `location` alias. On Android this maps to both `ACCESS_COARSE_LOCATION` and `ACCESS_FINE_LOCATION`. On iOS it maps to the standard location permission. + Location, + /// The `coarseLocation` alias. On Android this maps to `ACCESS_COARSE_LOCATION` only. On iOS it behaves the same as [`Location`](Self::Location). + CoarseLocation, +} + +/// The GPS coordinates of a [`Position`], along with the accuracy of each reading. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct Coordinates { + /// Latitude in decimal degrees. + pub latitude: f64, + /// Longitude in decimal degrees. + pub longitude: f64, + /// Accuracy level of the latitude and longitude coordinates in meters. + pub accuracy: f64, + /// Accuracy level of the altitude coordinate in meters, if available. + /// Available on all iOS versions and on Android 8 and above. + pub altitude_accuracy: Option, + /// The altitude the user is at, if available. + pub altitude: Option, + /// The speed the user is traveling, in meters per second, if available. + pub speed: Option, + /// The heading the user is facing, if available. + pub heading: Option, +} + +/// A geolocation reading, as returned by [`get_current_position`](crate::Geolocation::get_current_position) and reported through [`WatchEvent::Position`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct Position { + /// Creation time for these coordinates. + // TODO: Check if we're actually losing precision. + pub timestamp: u64, + /// The GPS coordinates along with the accuracy of the data. + pub coords: Coordinates, +} + +/// A single update sent through the channel callback registered with [`watch_position`](crate::Geolocation::watch_position). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(untagged)] +pub enum WatchEvent { + /// A new position was read successfully. + Position(Position), + /// The platform failed to read a position; the string is the platform-provided error message. + Error(String), +} diff --git a/plugins/geolocation/tsconfig.json b/plugins/geolocation/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/geolocation/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/global-shortcut/CHANGELOG.md b/plugins/global-shortcut/CHANGELOG.md new file mode 100644 index 0000000..4ac2141 --- /dev/null +++ b/plugins/global-shortcut/CHANGELOG.md @@ -0,0 +1,126 @@ +# Changelog + +## \[2.3.2] + +- [`cb863d57`](https://github.com/tauri-apps/plugins-workspace/commit/cb863d57c60790efc41b2bd77d065bd91b4ef2b1) ([#3408](https://github.com/tauri-apps/plugins-workspace/pull/3408) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update crate `global-hotkey` to 0.8 to fix cpu usage while pressing the keys on windows and to add F13-F24 mappings on Linux/X11. + +## \[2.3.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.1] + +- [`494d1fea`](https://github.com/tauri-apps/plugins-workspace/commit/494d1fea137ffd60da98b25305c9d666df62cc63) ([#2684](https://github.com/tauri-apps/plugins-workspace/pull/2684) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated `global-hotkey` crate to `0.7` to fix a panic when trying to register a key combination which was already registered by another program. No API changes. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`2c00c029`](https://github.com/tauri-apps/plugins-workspace/commit/2c00c0292c9127b81567de46691e8c0f73557261) ([#1630](https://github.com/tauri-apps/plugins-workspace/pull/1630) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused multi-word IIFE names to not be formatted correctly. For example the `barcode-scanner` was defined as `window.__TAURI_PLUGIN_CLIPBOARDMANAGER__` instead of `window.__TAURI_PLUGIN_CLIPBOARD_MANAGER__`. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`381a466d`](https://github.com/tauri-apps/plugins-workspace/commit/381a466db344e59a76b2a4d5785b2a0b64d4d373) ([#1117](https://github.com/tauri-apps/plugins-workspace/pull/1117) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Refactored the JS APIs: + + - Enhanced `register` and `unregister` to take either a single shortcut or an array. + - Removed `registerAll` instead use `register` with an array. +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`9c7eb359`](https://github.com/tauri-apps/plugins-workspace/commit/9c7eb35967ad10659eb4976bd6f77d9d270bfeed)([#1244](https://github.com/tauri-apps/plugins-workspace/pull/1244)) Refactored APIs to introduce new pressed and released events: + + - Added `ShortcutEvent` and `ShortcutState` types in Rust. + - Changed the handler function passed to `GlobalShortcut::on_shortcut`, `GlobalShortcut::on_all_shortcuts` and `Builder::with_handler` to take a 3rd argument of type `ShortcutEvent`. + - Added `ShortcutEvent` interface in JS. + - Changed `ShortcutHandler` type alias (which affects the JS `register` and `registerAll` APIs) to take `ShortcutEvent` instead of a string. +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. +- [`62dafda`](https://github.com/tauri-apps/plugins-workspace/commit/62dafda6526899b407a7c5a1bb269c5c0dfb2630)([#969](https://github.com/tauri-apps/plugins-workspace/pull/969)) **Breaking change** Refactored the plugin Rust APIs for better DX and flexibility: + + - Changed `Builder::with_handler` to be a method instead of a static method, it will also be triggered for any and all shortcuts even if the shortcut is registered through JS. + - Added `Builder::with_shortcut` and `Builder::with_shortcuts` to register shortcuts on the plugin builder. + - Added `on_shortcut` and `on_all_shortcuts` to register shortcuts with a handler. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Add permissions. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/global-shortcut/Cargo.toml b/plugins/global-shortcut/Cargo.toml new file mode 100644 index 0000000..6d31703 --- /dev/null +++ b/plugins/global-shortcut/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +description = "Register global hotkeys listeners on your Tauri application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-global-shortcut" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } + +[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] +global-hotkey = { version = "0.8", features = ["serde"] } diff --git a/plugins/global-shortcut/LICENSE.spdx b/plugins/global-shortcut/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/global-shortcut/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/global-shortcut/LICENSE_APACHE-2.0 b/plugins/global-shortcut/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/global-shortcut/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/global-shortcut/LICENSE_MIT b/plugins/global-shortcut/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/global-shortcut/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/global-shortcut/README.md b/plugins/global-shortcut/README.md new file mode 100644 index 0000000..a988826 --- /dev/null +++ b/plugins/global-shortcut/README.md @@ -0,0 +1,119 @@ +![plugin-global-shortcut](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/global-shortcut/banner.png) + +Register global shortcuts. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | x | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +# you can add the dependencies on the `[dependencies]` section if you do not target mobile +[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] +tauri-plugin-global-shortcut = "2.0.0" +# alternatively with Git: +tauri-plugin-global-shortcut = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-global-shortcut +# or +npm add @tauri-apps/plugin-global-shortcut +# or +yarn add @tauri-apps/plugin-global-shortcut +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .setup(|app| { + #[cfg(desktop)] + { + use tauri::Emitter; + use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState}; + + app.handle().plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_shortcuts(["ctrl+d", "alt+space"])? + .with_handler(|app, shortcut, event| { + if event.state == ShortcutState::Pressed { + if shortcut.matches(Modifiers::CONTROL, Code::KeyD) { + let _ = app.emit("shortcut-event", "Ctrl+D triggered"); + } + if shortcut.matches(Modifiers::ALT, Code::Space) { + let _ = app.emit("shortcut-event", "Alt+Space triggered"); + } + } + }) + .build(), + )?; + } + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript bindings: + +```javascript +import { register } from '@tauri-apps/plugin-global-shortcut' +await register('CommandOrControl+Shift+C', (event) => { + if (event.state === 'Pressed') { + console.log('Shortcut triggered') + } +}) +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/global-shortcut/SECURITY.md b/plugins/global-shortcut/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/global-shortcut/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/global-shortcut/api-iife.js b/plugins/global-shortcut/api-iife.js new file mode 100644 index 0000000..6e93962 --- /dev/null +++ b/plugins/global-shortcut/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_GLOBAL_SHORTCUT__=function(r,t){"use strict";return r.isRegistered=async function(r){return await t.invoke("plugin:global-shortcut|is_registered",{shortcut:r})},r.register=async function(r,n){const e=new t.Channel;return e.onmessage=n,await t.invoke("plugin:global-shortcut|register",{shortcuts:Array.isArray(r)?r:[r],handler:e})},r.unregister=async function(r){return await t.invoke("plugin:global-shortcut|unregister",{shortcuts:Array.isArray(r)?r:[r]})},r.unregisterAll=async function(){return await t.invoke("plugin:global-shortcut|unregister_all",{})},r}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"globalShortcut",{value:__TAURI_PLUGIN_GLOBAL_SHORTCUT__})} diff --git a/plugins/global-shortcut/banner.png b/plugins/global-shortcut/banner.png new file mode 100644 index 0000000..d74203a Binary files /dev/null and b/plugins/global-shortcut/banner.png differ diff --git a/plugins/global-shortcut/build.rs b/plugins/global-shortcut/build.rs new file mode 100644 index 0000000..20b7b7f --- /dev/null +++ b/plugins/global-shortcut/build.rs @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["register", "unregister", "unregister_all", "is_registered"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); +} diff --git a/plugins/global-shortcut/guest-js/index.ts b/plugins/global-shortcut/guest-js/index.ts new file mode 100644 index 0000000..8f30a69 --- /dev/null +++ b/plugins/global-shortcut/guest-js/index.ts @@ -0,0 +1,131 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Register global shortcuts. + * + * @module + */ + +import { invoke, Channel } from '@tauri-apps/api/core' + +/** + * Payload sent to a shortcut handler when a registered shortcut is pressed or released. + */ +export interface ShortcutEvent { + /** The shortcut definition that triggered this event, e.g. `CommandOrControl+Shift+C`. */ + shortcut: string + /** Numeric identifier derived from the shortcut's modifiers and key. */ + id: number + /** Whether the shortcut's key combination was pressed down or released. */ + state: 'Released' | 'Pressed' +} + +/** Callback invoked with a {@link ShortcutEvent} whenever a registered shortcut changes state. */ +export type ShortcutHandler = (event: ShortcutEvent) => void + +/** + * Register a global shortcut or a list of shortcuts. + * + * The handler is called when any of the registered shortcuts are pressed by the user. + * + * If the shortcut is already taken by another application, the handler will not be triggered. + * Make sure the shortcut is as unique as possible while still taking user experience into consideration. + * + * @example + * ```typescript + * import { register } from '@tauri-apps/plugin-global-shortcut'; + * + * // register a single hotkey + * await register('CommandOrControl+Shift+C', (event) => { + * if (event.state === "Pressed") { + * console.log('Shortcut triggered'); + * } + * }); + * + * // or register multiple hotkeys at once + * await register(['CommandOrControl+Shift+C', 'Alt+A'], (event) => { + * console.log(`Shortcut ${event.shortcut} triggered`); + * }); + * ``` + * + * @param shortcuts A shortcut definition, or a list of shortcut definitions, with modifiers and key separated by "+" e.g. CmdOrControl+Q + * @param handler Shortcut handler callback - takes the triggered shortcut as argument + * + * @since 2.0.0 + */ +async function register( + shortcuts: string | string[], + handler: ShortcutHandler +): Promise { + const h = new Channel() + h.onmessage = handler + + return await invoke('plugin:global-shortcut|register', { + shortcuts: Array.isArray(shortcuts) ? shortcuts : [shortcuts], + handler: h + }) +} + +/** + * Unregister a global shortcut or a list of shortcuts. + * + * @example + * ```typescript + * import { unregister } from '@tauri-apps/plugin-global-shortcut'; + * + * // unregister a single hotkey + * await unregister('CmdOrControl+Space'); + * + * // or unregister multiple hotkeys at the same time + * await unregister(['CmdOrControl+Space', 'Alt+A']); + * ``` + * + * @param shortcuts A shortcut definition, or a list of shortcut definitions, with modifiers and key separated by "+" e.g. CmdOrControl+Q + * + * @since 2.0.0 + */ +async function unregister(shortcuts: string | string[]): Promise { + return await invoke('plugin:global-shortcut|unregister', { + shortcuts: Array.isArray(shortcuts) ? shortcuts : [shortcuts] + }) +} + +/** + * Unregister all global shortcuts. + * + * @example + * ```typescript + * import { unregisterAll } from '@tauri-apps/plugin-global-shortcut'; + * await unregisterAll(); + * ``` + * @since 2.0.0 + */ +async function unregisterAll(): Promise { + return await invoke('plugin:global-shortcut|unregister_all', {}) +} + +/** + * Determines whether the given shortcut is registered by this application or not. + * + * If the shortcut is registered by another application, it will still return `false`. + * + * @example + * ```typescript + * import { isRegistered } from '@tauri-apps/plugin-global-shortcut'; + * const isRegistered = await isRegistered('CommandOrControl+P'); + * ``` + * + * @param shortcut shortcut definition, modifiers and key separated by "+" e.g. CmdOrControl+Q + * @returns A promise resolving to whether the shortcut is currently registered by this application. + * + * @since 2.0.0 + */ +async function isRegistered(shortcut: string): Promise { + return await invoke('plugin:global-shortcut|is_registered', { + shortcut + }) +} + +export { register, unregister, unregisterAll, isRegistered } diff --git a/plugins/global-shortcut/package.json b/plugins/global-shortcut/package.json new file mode 100644 index 0000000..21d2920 --- /dev/null +++ b/plugins/global-shortcut/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-global-shortcut", + "version": "2.3.2", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/global-shortcut/permissions/autogenerated/commands/is_registered.toml b/plugins/global-shortcut/permissions/autogenerated/commands/is_registered.toml new file mode 100644 index 0000000..2dd73ac --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/commands/is_registered.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-registered" +description = "Enables the is_registered command without any pre-configured scope." +commands.allow = ["is_registered"] + +[[permission]] +identifier = "deny-is-registered" +description = "Denies the is_registered command without any pre-configured scope." +commands.deny = ["is_registered"] diff --git a/plugins/global-shortcut/permissions/autogenerated/commands/register.toml b/plugins/global-shortcut/permissions/autogenerated/commands/register.toml new file mode 100644 index 0000000..4eec17d --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/commands/register.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register" +description = "Enables the register command without any pre-configured scope." +commands.allow = ["register"] + +[[permission]] +identifier = "deny-register" +description = "Denies the register command without any pre-configured scope." +commands.deny = ["register"] diff --git a/plugins/global-shortcut/permissions/autogenerated/commands/register_all.toml b/plugins/global-shortcut/permissions/autogenerated/commands/register_all.toml new file mode 100644 index 0000000..df5d0ec --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/commands/register_all.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register-all" +description = "Enables the register_all command without any pre-configured scope." +commands.allow = ["register_all"] + +[[permission]] +identifier = "deny-register-all" +description = "Denies the register_all command without any pre-configured scope." +commands.deny = ["register_all"] diff --git a/plugins/global-shortcut/permissions/autogenerated/commands/unregister.toml b/plugins/global-shortcut/permissions/autogenerated/commands/unregister.toml new file mode 100644 index 0000000..5d33c97 --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/commands/unregister.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unregister" +description = "Enables the unregister command without any pre-configured scope." +commands.allow = ["unregister"] + +[[permission]] +identifier = "deny-unregister" +description = "Denies the unregister command without any pre-configured scope." +commands.deny = ["unregister"] diff --git a/plugins/global-shortcut/permissions/autogenerated/commands/unregister_all.toml b/plugins/global-shortcut/permissions/autogenerated/commands/unregister_all.toml new file mode 100644 index 0000000..12c12f8 --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/commands/unregister_all.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unregister-all" +description = "Enables the unregister_all command without any pre-configured scope." +commands.allow = ["unregister_all"] + +[[permission]] +identifier = "deny-unregister-all" +description = "Denies the unregister_all command without any pre-configured scope." +commands.deny = ["unregister_all"] diff --git a/plugins/global-shortcut/permissions/autogenerated/reference.md b/plugins/global-shortcut/permissions/autogenerated/reference.md new file mode 100644 index 0000000..240224c --- /dev/null +++ b/plugins/global-shortcut/permissions/autogenerated/reference.md @@ -0,0 +1,146 @@ +## Default Permission + +No features are enabled by default, as we believe +the shortcuts can be inherently dangerous and it is +application specific if specific shortcuts should be +registered or unregistered. + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`global-shortcut:allow-is-registered` + + + +Enables the is_registered command without any pre-configured scope. + +
+ +`global-shortcut:deny-is-registered` + + + +Denies the is_registered command without any pre-configured scope. + +
+ +`global-shortcut:allow-register` + + + +Enables the register command without any pre-configured scope. + +
+ +`global-shortcut:deny-register` + + + +Denies the register command without any pre-configured scope. + +
+ +`global-shortcut:allow-register-all` + + + +Enables the register_all command without any pre-configured scope. + +
+ +`global-shortcut:deny-register-all` + + + +Denies the register_all command without any pre-configured scope. + +
+ +`global-shortcut:allow-unregister` + + + +Enables the unregister command without any pre-configured scope. + +
+ +`global-shortcut:deny-unregister` + + + +Denies the unregister command without any pre-configured scope. + +
+ +`global-shortcut:allow-unregister-all` + + + +Enables the unregister_all command without any pre-configured scope. + +
+ +`global-shortcut:deny-unregister-all` + + + +Denies the unregister_all command without any pre-configured scope. + +
diff --git a/plugins/global-shortcut/permissions/default.toml b/plugins/global-shortcut/permissions/default.toml new file mode 100644 index 0000000..fd32c51 --- /dev/null +++ b/plugins/global-shortcut/permissions/default.toml @@ -0,0 +1,10 @@ +"$schema" = "schemas/schema.json" +[default] +description = """ +No features are enabled by default, as we believe +the shortcuts can be inherently dangerous and it is +application specific if specific shortcuts should be +registered or unregistered. +""" + +permissions = [] diff --git a/plugins/global-shortcut/permissions/schemas/schema.json b/plugins/global-shortcut/permissions/schemas/schema.json new file mode 100644 index 0000000..1224869 --- /dev/null +++ b/plugins/global-shortcut/permissions/schemas/schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/global-shortcut/rollup.config.js b/plugins/global-shortcut/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/global-shortcut/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/global-shortcut/src/error.rs b/plugins/global-shortcut/src/error.rs new file mode 100644 index 0000000..9b8e5cb --- /dev/null +++ b/plugins/global-shortcut/src/error.rs @@ -0,0 +1,44 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, Serializer}; + +/// Errors that can happen while registering, unregistering or parsing global shortcuts. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// An error returned by the underlying `global_hotkey` crate, for example when the OS + /// refuses to register or unregister a shortcut, or when a shortcut string fails to parse. + #[error("{0}")] + GlobalHotkey(String), + /// Failed to receive the result of an operation dispatched to the main thread because the + /// sending end of the channel was dropped before it could reply. + #[error(transparent)] + RecvError(#[from] std::sync::mpsc::RecvError), + /// An error returned by the Tauri runtime, for example when dispatching a closure to run on + /// the main thread fails. + #[error(transparent)] + Tauri(#[from] tauri::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +impl From for Error { + fn from(value: global_hotkey::Error) -> Self { + Self::GlobalHotkey(value.to_string()) + } +} + +impl From for Error { + fn from(value: global_hotkey::hotkey::HotKeyParseError) -> Self { + Self::GlobalHotkey(value.to_string()) + } +} diff --git a/plugins/global-shortcut/src/lib.rs b/plugins/global-shortcut/src/lib.rs new file mode 100644 index 0000000..19a4fa1 --- /dev/null +++ b/plugins/global-shortcut/src/lib.rs @@ -0,0 +1,497 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Register global shortcuts. +//! +//! - Supported platforms: Windows, Linux and macOS. +//! - On macOS, registering and unregistering shortcuts must happen on the main thread, so every +//! [`GlobalShortcut`] operation dispatches to it and blocks until it completes. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] +#![cfg(not(any(target_os = "android", target_os = "ios")))] + +use std::{ + collections::HashMap, + str::FromStr, + sync::{Arc, Mutex}, +}; + +use global_hotkey::GlobalHotKeyEvent; +pub use global_hotkey::{ + hotkey::{Code, HotKey as Shortcut, Modifiers}, + GlobalHotKeyEvent as ShortcutEvent, HotKeyState as ShortcutState, +}; +use serde::Serialize; +use tauri::{ + ipc::Channel, + plugin::{Builder as PluginBuilder, TauriPlugin}, + AppHandle, Manager, Runtime, State, +}; + +mod error; + +pub use error::Error; +type Result = std::result::Result; + +type HotKeyId = u32; +type HandlerFn = Box, &Shortcut, ShortcutEvent) + Send + Sync + 'static>; + +/// Internal wrapper around a [`Shortcut`] that lets the shortcut-accepting APIs on +/// [`GlobalShortcut`] and [`Builder`] be generic over an already-parsed [`Shortcut`] or a +/// string accelerator such as `"CmdOrControl+Q"`. +pub struct ShortcutWrapper(Shortcut); + +impl From for ShortcutWrapper { + fn from(value: Shortcut) -> Self { + Self(value) + } +} + +impl TryFrom<&str> for ShortcutWrapper { + type Error = global_hotkey::hotkey::HotKeyParseError; + fn try_from(value: &str) -> std::result::Result { + Shortcut::from_str(value).map(ShortcutWrapper) + } +} + +struct RegisteredShortcut { + shortcut: Shortcut, + handler: Option>>, +} + +struct GlobalHotKeyManager(global_hotkey::GlobalHotKeyManager); + +/// SAFETY: we ensure it is run on main thread only +unsafe impl Send for GlobalHotKeyManager {} +/// SAFETY: we ensure it is run on main thread only +unsafe impl Sync for GlobalHotKeyManager {} + +/// The global shortcut APIs, accessible through [`GlobalShortcutExt::global_shortcut`]. +/// +/// Registering, unregistering and checking a shortcut all dispatch the underlying +/// `global_hotkey` call to the app's main thread and block until it finishes, since macOS +/// requires the hotkey manager to run on the main thread. +pub struct GlobalShortcut { + #[allow(dead_code)] + app: AppHandle, + manager: Arc, + shortcuts: Arc>>>, +} + +macro_rules! run_main_thread { + ($handle:expr, $manager:expr, |$m:ident| $ex:expr) => {{ + let (tx, rx) = std::sync::mpsc::channel(); + let manager = $manager.clone(); + let task = move || { + let f = |$m: &GlobalHotKeyManager| $ex; + let _ = tx.send(f(&*manager)); + }; + $handle.run_on_main_thread(task)?; + rx.recv()? + }}; +} + +impl GlobalShortcut { + fn register_internal, &Shortcut, ShortcutEvent) + Send + Sync + 'static>( + &self, + shortcut: Shortcut, + handler: Option, + ) -> Result<()> { + let id = shortcut.id(); + let handler = handler.map(|h| Arc::new(Box::new(h) as HandlerFn)); + run_main_thread!(self.app, self.manager, |m| m.0.register(shortcut))?; + self.shortcuts + .lock() + .unwrap() + .insert(id, RegisteredShortcut { shortcut, handler }); + Ok(()) + } + + fn register_multiple_internal(&self, shortcuts: S, handler: Option) -> Result<()> + where + S: IntoIterator, + F: Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static, + { + let handler = handler.map(|h| Arc::new(Box::new(h) as HandlerFn)); + + let hotkeys = shortcuts.into_iter().collect::>(); + + let mut shortcuts = self.shortcuts.lock().unwrap(); + for shortcut in hotkeys { + run_main_thread!(self.app, self.manager, |m| m.0.register(shortcut))?; + shortcuts.insert( + shortcut.id(), + RegisteredShortcut { + shortcut, + handler: handler.clone(), + }, + ); + } + + Ok(()) + } +} + +impl GlobalShortcut { + /// Register a shortcut. Returns an error if the shortcut is invalid or already registered + /// by this [`GlobalShortcut`] instance. + pub fn register(&self, shortcut: S) -> Result<()> + where + S: TryInto, + S::Error: std::error::Error, + { + self.register_internal( + try_into_shortcut(shortcut)?, + None::, &Shortcut, ShortcutEvent)>, + ) + } + + /// Register a shortcut with a handler that is called with the app handle, the triggered + /// [`Shortcut`] and the [`ShortcutEvent`] (whose `state` is [`ShortcutState::Pressed`] or + /// [`ShortcutState::Released`]) whenever the shortcut's key combination is pressed or + /// released. + pub fn on_shortcut(&self, shortcut: S, handler: F) -> Result<()> + where + S: TryInto, + S::Error: std::error::Error, + F: Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static, + { + self.register_internal(try_into_shortcut(shortcut)?, Some(handler)) + } + + /// Register multiple shortcuts. + pub fn register_multiple(&self, shortcuts: S) -> Result<()> + where + S: IntoIterator, + T: TryInto, + T::Error: std::error::Error, + { + let mut s = Vec::new(); + for shortcut in shortcuts { + s.push(try_into_shortcut(shortcut)?); + } + self.register_multiple_internal(s, None::, &Shortcut, ShortcutEvent)>) + } + + /// Register multiple shortcuts with a handler. + pub fn on_shortcuts(&self, shortcuts: S, handler: F) -> Result<()> + where + S: IntoIterator, + T: TryInto, + T::Error: std::error::Error, + F: Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static, + { + let mut s = Vec::new(); + for shortcut in shortcuts { + s.push(try_into_shortcut(shortcut)?); + } + self.register_multiple_internal(s, Some(handler)) + } + + /// Unregister a shortcut + pub fn unregister>(&self, shortcut: S) -> Result<()> + where + S::Error: std::error::Error, + { + let shortcut = try_into_shortcut(shortcut)?; + run_main_thread!(self.app, self.manager, |m| m.0.unregister(shortcut))?; + self.shortcuts.lock().unwrap().remove(&shortcut.id()); + Ok(()) + } + + /// Unregister multiple shortcuts. + pub fn unregister_multiple, S: IntoIterator>( + &self, + shortcuts: S, + ) -> Result<()> + where + T::Error: std::error::Error, + { + let mut mapped_shortcuts = Vec::new(); + for shortcut in shortcuts { + mapped_shortcuts.push(try_into_shortcut(shortcut)?); + } + + { + let mapped_shortcuts = mapped_shortcuts.clone(); + #[rustfmt::skip] + run_main_thread!(self.app, self.manager, |m| m.0.unregister_all(&mapped_shortcuts))?; + } + + let mut shortcuts = self.shortcuts.lock().unwrap(); + for s in mapped_shortcuts { + shortcuts.remove(&s.id()); + } + + Ok(()) + } + + /// Unregister all registered shortcuts. + pub fn unregister_all(&self) -> Result<()> { + let mut shortcuts = self.shortcuts.lock().unwrap(); + let hotkeys = std::mem::take(&mut *shortcuts); + let hotkeys = hotkeys.values().map(|s| s.shortcut).collect::>(); + #[rustfmt::skip] + let res = run_main_thread!(self.app, self.manager, |m| m.0.unregister_all(hotkeys.as_slice())); + res.map_err(Into::into) + } + + /// Determines whether the given shortcut is registered by this application or not. + /// + /// If the shortcut is registered by another application, it will still return `false`. + pub fn is_registered>(&self, shortcut: S) -> bool + where + S::Error: std::error::Error, + { + if let Ok(shortcut) = try_into_shortcut(shortcut) { + self.shortcuts.lock().unwrap().contains_key(&shortcut.id()) + } else { + false + } + } +} + +/// Extension trait for [`Manager`] implementors (e.g. [`AppHandle`]) that exposes access to the +/// global shortcut APIs. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tauri::Runtime; +/// use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +/// +/// fn setup(app: &tauri::App) -> Result<(), Box> { +/// app.global_shortcut() +/// .on_shortcut("CommandOrControl+Shift+C", |_app, shortcut, event| { +/// if event.state == ShortcutState::Pressed { +/// println!("{shortcut:?} pressed"); +/// } +/// })?; +/// +/// Ok(()) +/// } +/// ``` +pub trait GlobalShortcutExt { + /// Returns the [`GlobalShortcut`] instance managed by this plugin. + fn global_shortcut(&self) -> &GlobalShortcut; +} + +impl> GlobalShortcutExt for T { + fn global_shortcut(&self) -> &GlobalShortcut { + self.state::>().inner() + } +} + +fn parse_shortcut>(shortcut: S) -> Result { + shortcut.as_ref().parse().map_err(Into::into) +} + +fn try_into_shortcut>(shortcut: S) -> Result +where + S::Error: std::error::Error, +{ + shortcut + .try_into() + .map(|s| s.0) + .map_err(|e| Error::GlobalHotkey(e.to_string())) +} + +#[derive(Clone, Serialize)] +struct ShortcutJsEvent { + shortcut: String, + id: u32, + state: ShortcutState, +} + +#[tauri::command] +fn register( + _app: AppHandle, + global_shortcut: State<'_, GlobalShortcut>, + shortcuts: Vec, + handler: Channel, +) -> Result<()> { + let mut hotkeys = Vec::new(); + + let mut shortcut_map = HashMap::new(); + for shortcut in shortcuts { + let hotkey = parse_shortcut(&shortcut)?; + shortcut_map.insert(hotkey.id(), shortcut); + hotkeys.push(hotkey); + } + + global_shortcut.register_multiple_internal( + hotkeys, + Some( + move |_app: &AppHandle, shortcut: &Shortcut, e: ShortcutEvent| { + let js_event = ShortcutJsEvent { + id: e.id, + state: e.state, + shortcut: shortcut.into_string(), + }; + let _ = handler.send(js_event); + }, + ), + ) +} + +#[tauri::command] +fn unregister( + _app: AppHandle, + global_shortcut: State<'_, GlobalShortcut>, + shortcuts: Vec, +) -> Result<()> { + let mut hotkeys = Vec::new(); + for shortcut in shortcuts { + hotkeys.push(parse_shortcut(&shortcut)?); + } + global_shortcut.unregister_multiple(hotkeys) +} + +#[tauri::command] +fn unregister_all( + _app: AppHandle, + global_shortcut: State<'_, GlobalShortcut>, +) -> Result<()> { + global_shortcut.unregister_all() +} + +#[tauri::command] +fn is_registered( + _app: AppHandle, + global_shortcut: State<'_, GlobalShortcut>, + shortcut: String, +) -> Result { + Ok(global_shortcut.is_registered(parse_shortcut(shortcut)?)) +} + +/// Builder for the global shortcut plugin, letting you configure shortcuts and a handler that +/// are registered as soon as the plugin is set up, before [`Builder::build`] is called. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tauri::Runtime; +/// +/// fn setup(builder: tauri::Builder) -> tauri::Builder { +/// builder.plugin( +/// tauri_plugin_global_shortcut::Builder::new() +/// .with_shortcut("CommandOrControl+Shift+C") +/// .unwrap() +/// .with_handler(|_app, shortcut, event| { +/// println!("{shortcut:?}: {event:?}"); +/// }) +/// .build(), +/// ) +/// } +/// ``` +pub struct Builder { + shortcuts: Vec, + handler: Option>, +} + +impl Default for Builder { + fn default() -> Self { + Self { + shortcuts: Vec::new(), + handler: Default::default(), + } + } +} + +impl Builder { + /// Creates a new [`Builder`] with no shortcuts or handler configured. + pub fn new() -> Self { + Self::default() + } + + /// Add a shortcut to be registerd. + pub fn with_shortcut(mut self, shortcut: T) -> Result + where + T: TryInto, + T::Error: std::error::Error, + { + self.shortcuts.push(try_into_shortcut(shortcut)?); + Ok(self) + } + + /// Add multiple shortcuts to be registerd. + pub fn with_shortcuts(mut self, shortcuts: S) -> Result + where + S: IntoIterator, + T: TryInto, + T::Error: std::error::Error, + { + for shortcut in shortcuts { + self.shortcuts.push(try_into_shortcut(shortcut)?); + } + + Ok(self) + } + + /// Specify a global shortcut handler that will be triggered for any and all shortcuts. + pub fn with_handler, &Shortcut, ShortcutEvent) + Send + Sync + 'static>( + mut self, + handler: F, + ) -> Self { + self.handler.replace(Box::new(handler)); + self + } + + /// Builds the plugin. Shortcuts configured with [`Builder::with_shortcut`] / + /// [`Builder::with_shortcuts`] are registered when the plugin is set up, and the handler + /// configured with [`Builder::with_handler`] (if any) is called for every hotkey event, in + /// addition to any handler passed to [`GlobalShortcut::on_shortcut`] / + /// [`GlobalShortcut::on_shortcuts`] for that specific shortcut. + pub fn build(self) -> TauriPlugin { + let handler = self.handler; + let shortcuts = self.shortcuts; + PluginBuilder::new("global-shortcut") + .invoke_handler(tauri::generate_handler![ + register, + unregister, + unregister_all, + is_registered, + ]) + .setup(move |app, _api| { + let manager = global_hotkey::GlobalHotKeyManager::new()?; + let mut store = HashMap::>::new(); + for shortcut in shortcuts { + manager.register(shortcut)?; + store.insert( + shortcut.id(), + RegisteredShortcut { + shortcut, + handler: None, + }, + ); + } + + let shortcuts = Arc::new(Mutex::new(store)); + let shortcuts_ = shortcuts.clone(); + + let app_handle = app.clone(); + GlobalHotKeyEvent::set_event_handler(Some(move |e: GlobalHotKeyEvent| { + if let Some(shortcut) = shortcuts_.lock().unwrap().get(&e.id) { + if let Some(handler) = &shortcut.handler { + handler(&app_handle, &shortcut.shortcut, e); + } + if let Some(handler) = &handler { + handler(&app_handle, &shortcut.shortcut, e); + } + } + })); + + app.manage(GlobalShortcut { + app: app.clone(), + manager: Arc::new(GlobalHotKeyManager(manager)), + shortcuts, + }); + Ok(()) + }) + .build() + } +} diff --git a/plugins/global-shortcut/tsconfig.json b/plugins/global-shortcut/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/global-shortcut/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/haptics/CHANGELOG.md b/plugins/haptics/CHANGELOG.md new file mode 100644 index 0000000..e83e3ef --- /dev/null +++ b/plugins/haptics/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +## [2.3.3] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +## \[2.3.2] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.1] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.5] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.4] + +- [`a1b3fa27`](https://github.com/tauri-apps/plugins-workspace/commit/a1b3fa27f11022c9b6622b4fab12d93239eb05de) ([#2515](https://github.com/tauri-apps/plugins-workspace/pull/2515) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Re-exported the `Geolocation`, `Haptics`, `Notification`, and `Os` structs so that they show up on docs.rs. + +## \[2.2.3] + +- [`406e6f48`](https://github.com/tauri-apps/plugins-workspace/commit/406e6f484cdc13d35c50fb949f7489ca9eeccc44) ([#2323](https://github.com/tauri-apps/plugins-workspace/pull/2323) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused build failures when the `haptics` or `geolocation` plugin was used without their `specta` feature flag enabled. + +## \[2.2.2] + +- [`c9c13a0f`](https://github.com/tauri-apps/plugins-workspace/commit/c9c13a0fe7cdaac223843f5ba33176252f8e22f5) ([#2316](https://github.com/tauri-apps/plugins-workspace/pull/2316) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) **Breaking change:** `specta` integration is now behind a `specta` feature flag like in Tauri. +- [`c9c13a0f`](https://github.com/tauri-apps/plugins-workspace/commit/c9c13a0fe7cdaac223843f5ba33176252f8e22f5) ([#2316](https://github.com/tauri-apps/plugins-workspace/pull/2316) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Unlock and widen `specta` version range to match Tauri. No API changes. + +## \[2.2.1] + +- [`fb67ab2b`](https://github.com/tauri-apps/plugins-workspace/commit/fb67ab2b926502bfc20d6b43fbdd156691ea8526) ([#2281](https://github.com/tauri-apps/plugins-workspace/pull/2281) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Added `specta-util` to fix a "dependency not found" compilation error. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9606089b`](https://github.com/tauri-apps/plugins-workspace/commit/9606089b2add4a17f80ed5a09d59ce94824bd672) ([#1599](https://github.com/tauri-apps/plugins-workspace/pull/1599)) Initial release. +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. diff --git a/plugins/haptics/Cargo.toml b/plugins/haptics/Cargo.toml new file mode 100644 index 0000000..524a446 --- /dev/null +++ b/plugins/haptics/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "tauri-plugin-haptics" +description = "Haptic feedback and vibrations on Android and iOS" +version = "2.3.3" +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-haptics" +exclude = ["/guest-js", "/package.json", "/rollup.config.js", "/tsconfig.json"] + +[package.metadata.docs.rs] +targets = ["x86_64-linux-android"] + +[package.metadata.platforms.support] +windows = { level = "none", notes = "" } +linux = { level = "none", notes = "" } +macos = { level = "none", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +specta = { workspace = true, optional = true } + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } + +[features] +specta = ["dep:specta", "tauri/specta"] diff --git a/plugins/haptics/LICENSE.spdx b/plugins/haptics/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/haptics/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/haptics/LICENSE_APACHE-2.0 b/plugins/haptics/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/haptics/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/haptics/LICENSE_MIT b/plugins/haptics/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/haptics/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/haptics/README.md b/plugins/haptics/README.md new file mode 100644 index 0000000..8a7cd84 --- /dev/null +++ b/plugins/haptics/README.md @@ -0,0 +1,135 @@ +![haptics](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/haptics/banner.png) + +Haptic feedback and vibrations on Android and iOS. + +There are no standards/requirements for vibration support on Android, so the `feedback` APIs may not work correctly on more affordable phones, including recently released ones. + +| Platform | Supported | +| -------- | --------- | +| Linux | x | +| Windows | x | +| macOS | x | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-haptics = "2.0.0" +# alternatively with Git: +tauri-plugin-haptics = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + + + +```sh +pnpm add @tauri-apps/plugin-haptics +# or +npm add @tauri-apps/plugin-haptics +# or +yarn add @tauri-apps/plugin-haptics +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_haptics::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Second, add the required permissions in the project: + +`src-tauri/capabilities/default.json` + +```json + "permissions": [ + "haptics:allow-impact-feedback", + "haptics:allow-notification-feedback", + "haptics:allow-selection-feedback", + "haptics:allow-vibrate" + ] +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { + vibrate, + impactFeedback, + notificationFeedback, + selectionFeedback +} from '@tauri-apps/plugin-haptics' + +await vibrate(1) +await impactFeedback('medium') +await notificationFeedback('warning') +await selectionFeedback() +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Contributed By + + + + + + + + +
+ + CrabNebula + + + + Rescue.co + +
+ +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/haptics/SECURITY.md b/plugins/haptics/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/haptics/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/haptics/android/.gitignore b/plugins/haptics/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/haptics/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/haptics/android/build.gradle.kts b/plugins/haptics/android/build.gradle.kts new file mode 100644 index 0000000..a178826 --- /dev/null +++ b/plugins/haptics/android/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.haptics" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/haptics/android/consumer-rules.pro b/plugins/haptics/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/haptics/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/haptics/android/settings.gradle b/plugins/haptics/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/haptics/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/haptics/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/haptics/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..1b408ac --- /dev/null +++ b/plugins/haptics/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("app.tauri.haptics", appContext.packageName) + } +} diff --git a/plugins/haptics/android/src/main/AndroidManifest.xml b/plugins/haptics/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..042e61a --- /dev/null +++ b/plugins/haptics/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/haptics/android/src/main/java/HapticsPlugin.kt b/plugins/haptics/android/src/main/java/HapticsPlugin.kt new file mode 100644 index 0000000..5a6bf1b --- /dev/null +++ b/plugins/haptics/android/src/main/java/HapticsPlugin.kt @@ -0,0 +1,143 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import app.tauri.Logger +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.haptics.patterns.ImpactPatternHeavy +import app.tauri.haptics.patterns.ImpactPatternLight +import app.tauri.haptics.patterns.ImpactPatternMedium +import app.tauri.haptics.patterns.ImpactPatternRigid +import app.tauri.haptics.patterns.ImpactPatternSoft +import app.tauri.haptics.patterns.NotificationPatternError +import app.tauri.haptics.patterns.NotificationPatternSuccess +import app.tauri.haptics.patterns.NotificationPatternWarning +import app.tauri.haptics.patterns.Pattern +import app.tauri.haptics.patterns.SelectionPattern +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin +import com.fasterxml.jackson.annotation.JsonProperty + +@InvokeArg +class HapticsOptions { + var duration: Long = 300 +} + +@InvokeArg +class NotificationFeedbackArgs { + val type: NotificationFeedbackType = NotificationFeedbackType.Success +} + +@InvokeArg +enum class NotificationFeedbackType { + @JsonProperty("success") + Success, + @JsonProperty("warning") + Warning, + @JsonProperty("error") + Error; + + fun into(): Pattern { + return when(this) { + Success -> NotificationPatternSuccess + Warning -> NotificationPatternWarning + Error -> NotificationPatternError + } + } +} + +@InvokeArg +class ImpactFeedbackArgs { + val style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium +} + +@InvokeArg +enum class ImpactFeedbackStyle { + @JsonProperty("light") + Light, + @JsonProperty("medium") + Medium, + @JsonProperty("heavy") + Heavy, + @JsonProperty("soft") + Soft, + @JsonProperty("rigid") + Rigid; + + fun into(): Pattern { + return when(this) { + Light -> ImpactPatternLight + Medium -> ImpactPatternMedium + Heavy -> ImpactPatternHeavy + Soft -> ImpactPatternSoft + Rigid -> ImpactPatternRigid + } + } +} + +@TauriPlugin +class HapticsPlugin(private val activity: Activity): Plugin(activity) { + private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibManager = activity.applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + activity.applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + // + // TAURI COMMANDS + // + + @Command + fun vibrate(invoke: Invoke) { + val args = invoke.parseArgs(HapticsOptions::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(args.duration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(args.duration) + } + invoke.resolve() + } + + @Command + fun impactFeedback(invoke: Invoke) { + val args = invoke.parseArgs(ImpactFeedbackArgs::class.java) + vibratePattern(args.style.into()) + invoke.resolve() + } + + @Command + fun notificationFeedback(invoke: Invoke) { + val args = invoke.parseArgs(NotificationFeedbackArgs::class.java) + vibratePattern(args.type.into()) + invoke.resolve() + } + + // TODO: Consider breaking this up into Start,Change,End like capacitor + @Command + fun selectionFeedback(invoke: Invoke) { + vibratePattern(SelectionPattern) + invoke.resolve() + } + + // INTERNAL FUNCTIONS + + private fun vibratePattern(pattern: Pattern) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern.timings, pattern.amplitudes, -1)) + } else { + vibrator.vibrate(pattern.oldSDKPattern, -1) + } + } +} diff --git a/plugins/haptics/android/src/main/java/patterns/Impact.kt b/plugins/haptics/android/src/main/java/patterns/Impact.kt new file mode 100644 index 0000000..d40e750 --- /dev/null +++ b/plugins/haptics/android/src/main/java/patterns/Impact.kt @@ -0,0 +1,35 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val ImpactPatternLight = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternMedium = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) + +val ImpactPatternHeavy = Pattern( + longArrayOf(0, 60), + intArrayOf(0, 70), + longArrayOf(0, 61) +) + +val ImpactPatternSoft = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternRigid = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) \ No newline at end of file diff --git a/plugins/haptics/android/src/main/java/patterns/Notification.kt b/plugins/haptics/android/src/main/java/patterns/Notification.kt new file mode 100644 index 0000000..2f70c23 --- /dev/null +++ b/plugins/haptics/android/src/main/java/patterns/Notification.kt @@ -0,0 +1,23 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val NotificationPatternSuccess = Pattern( + longArrayOf(0, 40, 100, 40), + intArrayOf(0, 50, 0, 60), + longArrayOf(0, 40, 100, 40) +) + +val NotificationPatternWarning = Pattern( + longArrayOf(0, 40, 120, 60), + intArrayOf(0, 40, 0, 60), + longArrayOf(0, 40, 120, 60) +) + +val NotificationPatternError = Pattern( + longArrayOf(0, 60, 100, 40, 80, 50), + intArrayOf(0, 50, 0, 40, 0, 50), + longArrayOf(0, 60, 100, 40, 80, 50) +) diff --git a/plugins/haptics/android/src/main/java/patterns/Pattern.kt b/plugins/haptics/android/src/main/java/patterns/Pattern.kt new file mode 100644 index 0000000..5f99b02 --- /dev/null +++ b/plugins/haptics/android/src/main/java/patterns/Pattern.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +class Pattern ( + val timings: LongArray, + val amplitudes: IntArray, + val oldSDKPattern: LongArray +) {} \ No newline at end of file diff --git a/plugins/haptics/android/src/main/java/patterns/Selection.kt b/plugins/haptics/android/src/main/java/patterns/Selection.kt new file mode 100644 index 0000000..02dce4c --- /dev/null +++ b/plugins/haptics/android/src/main/java/patterns/Selection.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val SelectionPattern = Pattern ( + timings = longArrayOf(0, 50), + amplitudes = intArrayOf(0, 30), + oldSDKPattern = longArrayOf(0, 70) +) \ No newline at end of file diff --git a/plugins/haptics/android/src/test/java/ExampleUnitTest.kt b/plugins/haptics/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..562188f --- /dev/null +++ b/plugins/haptics/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/haptics/api-iife.js b/plugins/haptics/api-iife.js new file mode 100644 index 0000000..0f50b70 --- /dev/null +++ b/plugins/haptics/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_HAPTICS__=function(t,r){"use strict";const e={async vibrate(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|vibrate",{duration:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async impactFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|impact_feedback",{style:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async notificationFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|notification_feedback",{type:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async selectionFeedback(){try{return{status:"ok",data:await r.invoke("plugin:haptics|selection_feedback")}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}}};return t.impactFeedback=async function(t){return e.impactFeedback(t)},t.notificationFeedback=async function(t){return e.notificationFeedback(t)},t.selectionFeedback=async function(){return e.selectionFeedback()},t.vibrate=async function(t){return e.vibrate(t)},t}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"haptics",{value:__TAURI_PLUGIN_HAPTICS__})} diff --git a/plugins/haptics/build.rs b/plugins/haptics/build.rs new file mode 100644 index 0000000..2556cb6 --- /dev/null +++ b/plugins/haptics/build.rs @@ -0,0 +1,23 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &[ + "vibrate", + "impact_feedback", + "notification_feedback", + "selection_feedback", +]; + +fn main() { + let result = tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .try_build(); + + // when building documentation for Android the plugin build result is always Err() and is irrelevant to the crate documentation build + if !(cfg!(docsrs) && std::env::var("TARGET").unwrap().contains("android")) { + result.unwrap(); + } +} diff --git a/plugins/haptics/contributors/crabnebula.svg b/plugins/haptics/contributors/crabnebula.svg new file mode 100644 index 0000000..40e2413 --- /dev/null +++ b/plugins/haptics/contributors/crabnebula.svg @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/plugins/haptics/contributors/rescue.png b/plugins/haptics/contributors/rescue.png new file mode 100644 index 0000000..2b5916f Binary files /dev/null and b/plugins/haptics/contributors/rescue.png differ diff --git a/plugins/haptics/guest-js/bindings.ts b/plugins/haptics/guest-js/bindings.ts new file mode 100644 index 0000000..040e278 --- /dev/null +++ b/plugins/haptics/guest-js/bindings.ts @@ -0,0 +1,154 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// @ts-nocheck +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + +export const commands = { + async vibrate(duration: number): Promise> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:haptics|vibrate', { duration }) + } + } catch (e) { + if (e instanceof Error) throw e + else return { status: 'error', error: e as any } + } + }, + async impactFeedback( + style: ImpactFeedbackStyle + ): Promise> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:haptics|impact_feedback', { style }) + } + } catch (e) { + if (e instanceof Error) throw e + else return { status: 'error', error: e as any } + } + }, + async notificationFeedback( + type: NotificationFeedbackType + ): Promise> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:haptics|notification_feedback', { + type + }) + } + } catch (e) { + if (e instanceof Error) throw e + else return { status: 'error', error: e as any } + } + }, + async selectionFeedback(): Promise> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:haptics|selection_feedback') + } + } catch (e) { + if (e instanceof Error) throw e + else return { status: 'error', error: e as any } + } + } +} + +/** user-defined events **/ + +/* export const events = __makeEvents__<{ + randomNumber: RandomNumber; +}>({ + randomNumber: "plugin:haptics:random-number", +}); */ + +/** user-defined statics **/ + +/** user-defined types **/ + +export type Error = never +/** + * The style of an impact-feedback haptic. + * + * On iOS this maps directly to a `UIImpactFeedbackGenerator.FeedbackStyle` case. On Android, + * which has no equivalent system API, each style instead plays a distinct vibration waveform of + * increasing intensity. Has no effect on desktop platforms. Defaults to `Medium`. + */ +export type ImpactFeedbackStyle = + | 'light' + | 'medium' + | 'heavy' + | 'soft' + | 'rigid' +/** + * The type of notification feedback, indicating the outcome of a task or action. + * + * On iOS this maps directly to a `UINotificationFeedbackGenerator.FeedbackType` case. On + * Android, which has no equivalent system API, each type instead plays a distinct vibration + * waveform. Has no effect on desktop platforms. Defaults to `Success`. + */ +export type NotificationFeedbackType = 'success' | 'warning' | 'error' +//export type RandomNumber = number; + +/** tauri-specta globals **/ + +import { invoke as TAURI_INVOKE } from '@tauri-apps/api/core' +import * as TAURI_API_EVENT from '@tauri-apps/api/event' +import { type WebviewWindow as __WebviewWindow__ } from '@tauri-apps/api/webviewWindow' + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback + ) => ReturnType> + once: ( + cb: TAURI_API_EVENT.EventCallback + ) => ReturnType> + emit: T extends null + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType +} + +export type Result = + | { status: 'ok'; data: T } + | { status: 'error'; error: E } + +function __makeEvents__>( + mappings: Record +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__ + } + }, + { + get: (_, event) => { + const name = mappings[event as keyof T] + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg) + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case 'listen': + return (arg: any) => TAURI_API_EVENT.listen(name, arg) + case 'once': + return (arg: any) => TAURI_API_EVENT.once(name, arg) + case 'emit': + return (arg: any) => TAURI_API_EVENT.emit(name, arg) + } + } + }) + } + } + ) +} diff --git a/plugins/haptics/guest-js/index.ts b/plugins/haptics/guest-js/index.ts new file mode 100644 index 0000000..9c477aa --- /dev/null +++ b/plugins/haptics/guest-js/index.ts @@ -0,0 +1,104 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Trigger haptic feedback on Android and iOS. + * + * @module + */ + +import { commands } from './bindings' +import type { + ImpactFeedbackStyle, + NotificationFeedbackType, + Result, + Error +} from './bindings' + +/** + * Triggers a vibration for `duration` milliseconds. + * + * Has no effect on desktop platforms. + * + * @example + * ```typescript + * import { vibrate } from '@tauri-apps/plugin-haptics' + * await vibrate(300) + * ``` + * + * @param duration Duration of the vibration, in milliseconds. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function vibrate(duration: number): Promise> { + return commands.vibrate(duration) +} + +/** + * Triggers an impact-feedback haptic, indicating a collision between user interface elements. + * + * On iOS this maps to a `UIImpactFeedbackGenerator` of the given style. On Android, which has + * no equivalent system API, each style plays a distinct vibration waveform. Has no effect on + * desktop platforms. + * + * @example + * ```typescript + * import { impactFeedback } from '@tauri-apps/plugin-haptics' + * await impactFeedback('medium') + * ``` + * + * @param style The style of the impact. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function impactFeedback( + style: ImpactFeedbackStyle +): Promise> { + return commands.impactFeedback(style) +} + +/** + * Triggers a notification-feedback haptic, indicating the outcome of a task or action. + * + * On iOS this maps to a `UINotificationFeedbackGenerator` of the given type. On Android, which + * has no equivalent system API, each type plays a distinct vibration waveform. Has no effect on + * desktop platforms. + * + * @example + * ```typescript + * import { notificationFeedback } from '@tauri-apps/plugin-haptics' + * await notificationFeedback('success') + * ``` + * + * @param type The outcome to convey. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function notificationFeedback( + type: NotificationFeedbackType +): Promise> { + return commands.notificationFeedback(type) +} + +/** + * Triggers a haptic indicating that a selection changed, e.g. the value of a picker control. + * + * Has no effect on desktop platforms. + * + * @example + * ```typescript + * import { selectionFeedback } from '@tauri-apps/plugin-haptics' + * await selectionFeedback() + * ``` + * + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function selectionFeedback(): Promise> { + return commands.selectionFeedback() +} + +export { ImpactFeedbackStyle, NotificationFeedbackType } from './bindings' + +// export { events }; diff --git a/plugins/haptics/ios/.gitignore b/plugins/haptics/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/haptics/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/haptics/ios/Package.swift b/plugins/haptics/ios/Package.swift new file mode 100644 index 0000000..a64b989 --- /dev/null +++ b/plugins/haptics/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-haptics", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-haptics", + type: .static, + targets: ["tauri-plugin-haptics"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-haptics", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/haptics/ios/README.md b/plugins/haptics/ios/README.md new file mode 100644 index 0000000..3f7138b --- /dev/null +++ b/plugins/haptics/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin Haptics + +A description of this package. diff --git a/plugins/haptics/ios/Sources/HapticsPlugin.swift b/plugins/haptics/ios/Sources/HapticsPlugin.swift new file mode 100644 index 0000000..4889c8a --- /dev/null +++ b/plugins/haptics/ios/Sources/HapticsPlugin.swift @@ -0,0 +1,133 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import AudioToolbox +import CoreHaptics +import SwiftRs +import Tauri +import UIKit +import WebKit + +class ImpactFeedbackOptions: Decodable { + let style: ImpactFeedbackStyle +} + +enum ImpactFeedbackStyle: String, Decodable { + case light, medium, heavy, soft, rigid + + func into() -> UIImpactFeedbackGenerator.FeedbackStyle { + switch self { + case .light: + return .light + case .medium: + return .medium + case .heavy: + return .heavy + case .soft: + return .soft + case .rigid: + return .rigid + } + } +} + +class NotificationFeedbackOptions: Decodable { + let type: NotificationFeedbackType +} + +enum NotificationFeedbackType: String, Decodable { + case success, warning, error + + func into() -> UINotificationFeedbackGenerator.FeedbackType { + switch self { + case .success: + return .success + case .warning: + return .warning + case .error: + return .error + } + } +} + +class VibrateOptions: Decodable { + // TODO: Array + let duration: Double +} + +class HapticsPlugin: Plugin { + // + // Tauri commands + // + + @objc public func vibrate(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(VibrateOptions.self) + if CHHapticEngine.capabilitiesForHardware().supportsHaptics { + do { + let engine = try CHHapticEngine() + try engine.start() + engine.resetHandler = { [] in + do { + try engine.start() + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + // TODO: Make some of this (or all) configurable? + let intensity: CHHapticEventParameter = CHHapticEventParameter( + parameterID: .hapticIntensity, value: 1.0) + let sharpness: CHHapticEventParameter = CHHapticEventParameter( + parameterID: .hapticSharpness, value: 1.0) + let continuousEvent = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [intensity, sharpness], + relativeTime: 0.0, + duration: args.duration / 1000 + ) + let pattern = try CHHapticPattern(events: [continuousEvent], parameters: []) + let player = try engine.makePlayer(with: pattern) + + try player.start(atTime: 0) + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + + invoke.resolve() + } + + @objc public func impactFeedback(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(ImpactFeedbackOptions.self) + let generator = UIImpactFeedbackGenerator(style: args.style.into()) + generator.prepare() + generator.impactOccurred() + + invoke.resolve() + } + + @objc public func notificationFeedback(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(NotificationFeedbackOptions.self) + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(args.type.into()) + + invoke.resolve() + } + + // TODO: Consider breaking this up into Start,Change,End like capacitor + @objc public func selectionFeedback(_ invoke: Invoke) throws { + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + + invoke.resolve() + } +} + +@_cdecl("init_plugin_haptics") +func initPlugin() -> Plugin { + return HapticsPlugin() +} diff --git a/plugins/haptics/ios/Tests/PluginTests/PluginTests.swift b/plugins/haptics/ios/Tests/PluginTests/PluginTests.swift new file mode 100644 index 0000000..99992ce --- /dev/null +++ b/plugins/haptics/ios/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import XCTest +@testable import ExamplePlugin + +final class ExamplePluginTests: XCTestCase { + func testExample() throws { + let plugin = ExamplePlugin() + } +} diff --git a/plugins/haptics/package.json b/plugins/haptics/package.json new file mode 100644 index 0000000..0a848b5 --- /dev/null +++ b/plugins/haptics/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-haptics", + "version": "2.3.3", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/haptics/permissions/autogenerated/commands/impact_feedback.toml b/plugins/haptics/permissions/autogenerated/commands/impact_feedback.toml new file mode 100644 index 0000000..13fce39 --- /dev/null +++ b/plugins/haptics/permissions/autogenerated/commands/impact_feedback.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-impact-feedback" +description = "Enables the impact_feedback command without any pre-configured scope." +commands.allow = ["impact_feedback"] + +[[permission]] +identifier = "deny-impact-feedback" +description = "Denies the impact_feedback command without any pre-configured scope." +commands.deny = ["impact_feedback"] diff --git a/plugins/haptics/permissions/autogenerated/commands/notification_feedback.toml b/plugins/haptics/permissions/autogenerated/commands/notification_feedback.toml new file mode 100644 index 0000000..0c2782c --- /dev/null +++ b/plugins/haptics/permissions/autogenerated/commands/notification_feedback.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-notification-feedback" +description = "Enables the notification_feedback command without any pre-configured scope." +commands.allow = ["notification_feedback"] + +[[permission]] +identifier = "deny-notification-feedback" +description = "Denies the notification_feedback command without any pre-configured scope." +commands.deny = ["notification_feedback"] diff --git a/plugins/haptics/permissions/autogenerated/commands/selection_feedback.toml b/plugins/haptics/permissions/autogenerated/commands/selection_feedback.toml new file mode 100644 index 0000000..63e645d --- /dev/null +++ b/plugins/haptics/permissions/autogenerated/commands/selection_feedback.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-selection-feedback" +description = "Enables the selection_feedback command without any pre-configured scope." +commands.allow = ["selection_feedback"] + +[[permission]] +identifier = "deny-selection-feedback" +description = "Denies the selection_feedback command without any pre-configured scope." +commands.deny = ["selection_feedback"] diff --git a/plugins/haptics/permissions/autogenerated/commands/vibrate.toml b/plugins/haptics/permissions/autogenerated/commands/vibrate.toml new file mode 100644 index 0000000..4c2fe94 --- /dev/null +++ b/plugins/haptics/permissions/autogenerated/commands/vibrate.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-vibrate" +description = "Enables the vibrate command without any pre-configured scope." +commands.allow = ["vibrate"] + +[[permission]] +identifier = "deny-vibrate" +description = "Denies the vibrate command without any pre-configured scope." +commands.deny = ["vibrate"] diff --git a/plugins/haptics/permissions/autogenerated/reference.md b/plugins/haptics/permissions/autogenerated/reference.md new file mode 100644 index 0000000..82a911b --- /dev/null +++ b/plugins/haptics/permissions/autogenerated/reference.md @@ -0,0 +1,113 @@ +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`haptics:allow-impact-feedback` + + + +Enables the impact_feedback command without any pre-configured scope. + +
+ +`haptics:deny-impact-feedback` + + + +Denies the impact_feedback command without any pre-configured scope. + +
+ +`haptics:allow-notification-feedback` + + + +Enables the notification_feedback command without any pre-configured scope. + +
+ +`haptics:deny-notification-feedback` + + + +Denies the notification_feedback command without any pre-configured scope. + +
+ +`haptics:allow-selection-feedback` + + + +Enables the selection_feedback command without any pre-configured scope. + +
+ +`haptics:deny-selection-feedback` + + + +Denies the selection_feedback command without any pre-configured scope. + +
+ +`haptics:allow-vibrate` + + + +Enables the vibrate command without any pre-configured scope. + +
+ +`haptics:deny-vibrate` + + + +Denies the vibrate command without any pre-configured scope. + +
diff --git a/plugins/haptics/permissions/schemas/schema.json b/plugins/haptics/permissions/schemas/schema.json new file mode 100644 index 0000000..ed217dc --- /dev/null +++ b/plugins/haptics/permissions/schemas/schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the impact_feedback command without any pre-configured scope.", + "type": "string", + "const": "allow-impact-feedback", + "markdownDescription": "Enables the impact_feedback command without any pre-configured scope." + }, + { + "description": "Denies the impact_feedback command without any pre-configured scope.", + "type": "string", + "const": "deny-impact-feedback", + "markdownDescription": "Denies the impact_feedback command without any pre-configured scope." + }, + { + "description": "Enables the notification_feedback command without any pre-configured scope.", + "type": "string", + "const": "allow-notification-feedback", + "markdownDescription": "Enables the notification_feedback command without any pre-configured scope." + }, + { + "description": "Denies the notification_feedback command without any pre-configured scope.", + "type": "string", + "const": "deny-notification-feedback", + "markdownDescription": "Denies the notification_feedback command without any pre-configured scope." + }, + { + "description": "Enables the selection_feedback command without any pre-configured scope.", + "type": "string", + "const": "allow-selection-feedback", + "markdownDescription": "Enables the selection_feedback command without any pre-configured scope." + }, + { + "description": "Denies the selection_feedback command without any pre-configured scope.", + "type": "string", + "const": "deny-selection-feedback", + "markdownDescription": "Denies the selection_feedback command without any pre-configured scope." + }, + { + "description": "Enables the vibrate command without any pre-configured scope.", + "type": "string", + "const": "allow-vibrate", + "markdownDescription": "Enables the vibrate command without any pre-configured scope." + }, + { + "description": "Denies the vibrate command without any pre-configured scope.", + "type": "string", + "const": "deny-vibrate", + "markdownDescription": "Denies the vibrate command without any pre-configured scope." + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/haptics/rollup.config.js b/plugins/haptics/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/haptics/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/haptics/src/commands.rs b/plugins/haptics/src/commands.rs new file mode 100644 index 0000000..76f097c --- /dev/null +++ b/plugins/haptics/src/commands.rs @@ -0,0 +1,33 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{command, AppHandle, Runtime}; + +use crate::{HapticsExt, ImpactFeedbackStyle, NotificationFeedbackType, Result}; + +#[command] +pub(crate) async fn vibrate(app: AppHandle, duration: u32) -> Result<()> { + app.haptics().vibrate(duration) +} + +#[command] +pub(crate) async fn impact_feedback( + app: AppHandle, + style: ImpactFeedbackStyle, +) -> Result<()> { + app.haptics().impact_feedback(style) +} + +#[command] +pub(crate) async fn notification_feedback( + app: AppHandle, + r#type: NotificationFeedbackType, +) -> Result<()> { + app.haptics().notification_feedback(r#type) +} + +#[command] +pub(crate) async fn selection_feedback(app: AppHandle) -> Result<()> { + app.haptics().selection_feedback() +} diff --git a/plugins/haptics/src/desktop.rs b/plugins/haptics/src/desktop.rs new file mode 100644 index 0000000..d036edd --- /dev/null +++ b/plugins/haptics/src/desktop.rs @@ -0,0 +1,44 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Haptics(app.clone())) +} + +/// Access to the haptics APIs. +pub struct Haptics(AppHandle); + +impl Haptics { + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. + pub fn vibrate(&self, _duration: u32) -> crate::Result<()> { + Ok(()) + } + + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. + pub fn impact_feedback(&self, _style: ImpactFeedbackStyle) -> crate::Result<()> { + Ok(()) + } + + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. + pub fn notification_feedback(&self, _type: NotificationFeedbackType) -> crate::Result<()> { + Ok(()) + } + + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. + pub fn selection_feedback(&self) -> crate::Result<()> { + Ok(()) + } +} diff --git a/plugins/haptics/src/error.rs b/plugins/haptics/src/error.rs new file mode 100644 index 0000000..3a24755 --- /dev/null +++ b/plugins/haptics/src/error.rs @@ -0,0 +1,35 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for a [`std::result::Result`] with the error type [`Error`]. +pub type Result = std::result::Result; + +// TODO: Improve Error handling (different typed errors instead of one (stringified) PluginInvokeError for all mobile errors) + +/// Errors returned by the haptics APIs. +#[derive(Debug, thiserror::Error)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub enum Error { + /// The call to the Android or iOS plugin implementation failed, either because the + /// arguments could not be serialized, the response could not be deserialized, or the + /// native side rejected the call. Only produced on Android and iOS. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke( + #[cfg_attr(feature = "specta", specta(skip))] + #[from] + tauri::plugin::mobile::PluginInvokeError, + ), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/haptics/src/lib.rs b/plugins/haptics/src/lib.rs new file mode 100644 index 0000000..7984beb --- /dev/null +++ b/plugins/haptics/src/lib.rs @@ -0,0 +1,64 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Trigger haptic feedback on supported devices. +//! +//! ## Cargo features +//! +//! - **specta**: Add support for [`specta::specta`](https://docs.rs/specta/2.0.0-rc.25/specta/attr.specta.html) on structs like [`ImpactFeedbackStyle`], [`NotificationFeedbackType`]. + +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +pub use desktop::Haptics; +#[cfg(mobile)] +pub use mobile::Haptics; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the haptics APIs. +pub trait HapticsExt { + /// Returns a handle to the [`Haptics`] APIs. + fn haptics(&self) -> &Haptics; +} + +impl> crate::HapticsExt for T { + fn haptics(&self) -> &Haptics { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("haptics") + .invoke_handler(tauri::generate_handler![ + commands::vibrate, + commands::impact_feedback, + commands::notification_feedback, + commands::selection_feedback + ]) + .setup(|app, api| { + #[cfg(mobile)] + let haptics = mobile::init(app, api)?; + #[cfg(desktop)] + let haptics = desktop::init(app, api)?; + app.manage(haptics); + Ok(()) + }) + .build() +} diff --git a/plugins/haptics/src/mobile.rs b/plugins/haptics/src/mobile.rs new file mode 100644 index 0000000..42ae245 --- /dev/null +++ b/plugins/haptics/src/mobile.rs @@ -0,0 +1,105 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Serialize}; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.haptics"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_haptics); + +// initializes the Kotlin or Swift plugin classes +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "HapticsPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_haptics)?; + Ok(Haptics(handle)) +} + +/// Access to the haptics APIs. +pub struct Haptics(PluginHandle); + +impl Haptics { + /// Triggers a vibration for `duration` milliseconds. + /// + /// On iOS this plays a continuous [Core Haptics](https://developer.apple.com/documentation/corehaptics) + /// pattern when the device supports it, falling back to the system alert vibration + /// otherwise. On Android it uses [`Vibrator`](https://developer.android.com/reference/android/os/Vibrator). + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. + pub fn vibrate(&self, duration: u32) -> crate::Result<()> { + self.0 + .run_mobile_plugin("vibrate", VibratePayload { duration }) + .map_err(Into::into) + } + + /// Triggers an impact-feedback haptic with the given [`ImpactFeedbackStyle`]. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. + pub fn impact_feedback(&self, style: ImpactFeedbackStyle) -> crate::Result<()> { + self.0 + .run_mobile_plugin("impactFeedback", ImpactFeedbackPayload { style }) + .map_err(Into::into) + } + + /// Triggers a notification-feedback haptic for the given [`NotificationFeedbackType`]. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. + pub fn notification_feedback(&self, r#type: NotificationFeedbackType) -> crate::Result<()> { + self.0 + .run_mobile_plugin( + "notificationFeedback", + NotificationFeedbackPayload { r#type }, + ) + .map_err(Into::into) + } + + /// Triggers a haptic indicating that a selection changed, e.g. when the value of a picker + /// control changes. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. + pub fn selection_feedback(&self) -> crate::Result<()> { + self.0 + .run_mobile_plugin("selectionFeedback", ()) + .map_err(Into::into) + } +} + +#[derive(Serialize)] +struct VibratePayload { + duration: u32, +} + +#[derive(Serialize)] +struct ImpactFeedbackPayload { + style: ImpactFeedbackStyle, +} + +#[derive(Serialize)] +struct NotificationFeedbackPayload { + r#type: NotificationFeedbackType, +} diff --git a/plugins/haptics/src/models.rs b/plugins/haptics/src/models.rs new file mode 100644 index 0000000..d59cc20 --- /dev/null +++ b/plugins/haptics/src/models.rs @@ -0,0 +1,54 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize}; +/* +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct HapticsOptions { + // TODO: support array to match web api + pub duration: u32, +} + */ + +/// The style of an impact-feedback haptic. +/// +/// On iOS this maps directly to a `UIImpactFeedbackGenerator.FeedbackStyle` case. On Android, +/// which has no equivalent system API, each style instead plays a distinct vibration waveform of +/// increasing intensity. Has no effect on desktop platforms. Defaults to `Medium`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub enum ImpactFeedbackStyle { + /// A collision between small, light user interface elements. + Light, + /// A collision between moderately sized user interface elements. + #[default] + Medium, + /// A collision between large, heavy user interface elements. + Heavy, + /// A soft, muted impact between user interface elements. + Soft, + /// A sharp, rigid impact between user interface elements. + Rigid, +} + +/// The type of notification feedback, indicating the outcome of a task or action. +/// +/// On iOS this maps directly to a `UINotificationFeedbackGenerator.FeedbackType` case. On +/// Android, which has no equivalent system API, each type instead plays a distinct vibration +/// waveform. Has no effect on desktop platforms. Defaults to `Success`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub enum NotificationFeedbackType { + /// A task or action has completed successfully. + #[default] + Success, + /// A task or action has produced a warning. + Warning, + /// A task or action has failed. + Error, +} diff --git a/plugins/haptics/tsconfig.json b/plugins/haptics/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/haptics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/http/CHANGELOG.md b/plugins/http/CHANGELOG.md new file mode 100644 index 0000000..ed6b948 --- /dev/null +++ b/plugins/http/CHANGELOG.md @@ -0,0 +1,352 @@ +# Changelog + +## [2.7.0] + +- [`1198a524`](https://github.com/tauri-apps/plugins-workspace/commit/1198a524b710abf2abeb1d9bd7b252402d26ca6d) **Security:** Added the `scopeRedirects` plugin configuration option, which checks the URL scope on every hop of a redirect chain instead of only on the URL requested by the frontend. Without it, a server on an allowed origin can redirect the request to any other origin - including `localhost` services, internal hosts and cloud metadata endpoints - and the plugin follows it, returning the response to the webview. + + ```json + { + "plugins": { + "http": { + "scopeRedirects": true + } + } + } + ``` + + It is opt-in because a redirect to a URL that is not allowed by the scope now fails with `url not allowed on the configured scope` instead of being followed, so applications that rely on being redirected outside of their scope must add the redirect target to the scope. **This will become the default in v3.** + + Note that `tauri_plugin_http::init()` now returns `TauriPlugin>` instead of `TauriPlugin`. + +## [2.6.1] + +- [`a21555dd`](https://github.com/tauri-apps/plugins-workspace/commit/a21555ddd2eaadfed23848912fe2802c2ba7579e) ([#3566](https://github.com/tauri-apps/plugins-workspace/pull/3566) by [@followdarko](https://github.com/tauri-apps/plugins-workspace/../../followdarko)) Fix unhandled promise rejections on every `fetch` teardown: the request/body cleanup commands were fired as floating promises, and releasing an already-released resource rejects with `The resource id N is invalid.`. `dropBody` is now idempotent and both cleanup calls handle their own rejection. + +## [2.6.0] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. +- [`19fb3926`](https://github.com/tauri-apps/plugins-workspace/commit/19fb3926fd24d95371bc3a7593256861df406436) ([#3528](https://github.com/tauri-apps/plugins-workspace/pull/3528) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Added `system-proxy` feature to replace the old `macos-system-configuration` feature flag to match the new reqwest API. + +### Dependencies + +- Upgraded to `fs-js@2.5.2` + +## \[2.5.9] + +### Dependencies + +- Upgraded to `fs-js@2.5.1` + +## \[2.5.8] + +- [`29712892`](https://github.com/tauri-apps/plugins-workspace/commit/29712892526cfc2d35c9002e0a56925084ae1b73) ([#3252](https://github.com/tauri-apps/plugins-workspace/pull/3252) by [@NVolcz](https://github.com/tauri-apps/plugins-workspace/../../NVolcz)) Correct Response header initialization to support cloning and ensure Set-Cookie visibility. + +### Dependencies + +- Upgraded to `fs-js@2.5.0` + +## \[2.5.7] + +- [`61e9b0ab`](https://github.com/tauri-apps/plugins-workspace/commit/61e9b0ab64c56cc5f7a5cb3b92b386671da6e0a2) ([#3228](https://github.com/tauri-apps/plugins-workspace/pull/3228)) Cleanup resource when the returned `ReadableStream.cancel` is called to avoid memory leaks + +## \[2.5.6] + +- [`b1dbee2c`](https://github.com/tauri-apps/plugins-workspace/commit/b1dbee2c55c1aa7b64732c4cc9f8cb20520b8666) ([#3210](https://github.com/tauri-apps/plugins-workspace/pull/3210) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused the Origin header to always be `null` on macOS, iOS and Linux. + +## \[2.5.5] + +- [`e8915f17`](https://github.com/tauri-apps/plugins-workspace/commit/e8915f17e418138f0776870353cd6ce7254b0473) ([#2562](https://github.com/tauri-apps/plugins-workspace/pull/2562) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix aborting a request in the middle of a streaming response. + +### Dependencies + +- Upgraded to `fs-js@2.4.5` + +## \[2.5.4] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +### Dependencies + +- Upgraded to `fs-js@2.4.4` + +## \[2.5.3] + +### Dependencies + +- Upgraded to `fs-js@2.4.3` + +## \[2.5.2] + +### Dependencies + +- Upgraded to `fs-js@2.4.2` + +## \[2.5.1] + +### Dependencies + +- Upgraded to `fs-js@2.4.1` + +## \[2.5.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +### Dependencies + +- Upgraded to `fs-js@2.4.0` + +## \[2.4.4] + +- [`ff384cba`](https://github.com/tauri-apps/plugins-workspace/commit/ff384cbabe82ae715798a4ee49fd07ffcfbcdb5d) ([#2636](https://github.com/tauri-apps/plugins-workspace/pull/2636) by [@asomethings](https://github.com/tauri-apps/plugins-workspace/../../asomethings)) Properly handle responses with status code 204. + +### Dependencies + +- Upgraded to `fs-js@2.3.0` + +## \[2.4.3] + +- [`37c0477a`](https://github.com/tauri-apps/plugins-workspace/commit/37c0477afe926d326573f1827045875ce8bf8187) ([#2561](https://github.com/tauri-apps/plugins-workspace/pull/2561)) Add `zstd` cargo feature flag to enable `reqwest/zstd` flag. +- [`9ebbfb2e`](https://github.com/tauri-apps/plugins-workspace/commit/9ebbfb2e3ccef8e0f277a0c02fe6b399b41feeb6) ([#1978](https://github.com/tauri-apps/plugins-workspace/pull/1978)) Persist cookies to disk and load it on next app start. + +### Dependencies + +- Upgraded to `fs-js@2.2.1` + +## \[2.4.2] + +- [`a15eedf3`](https://github.com/tauri-apps/plugins-workspace/commit/a15eedf37854344f7ffbcb0d373d848563817011) ([#2535](https://github.com/tauri-apps/plugins-workspace/pull/2535) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix `fetch` occasionally throwing an error due to trying to close the underline stream twice. + +## \[2.4.1] + +- [`d3183aa9`](https://github.com/tauri-apps/plugins-workspace/commit/d3183aa99da7ca67e627394132ddeb3b85ccef06) ([#2522](https://github.com/tauri-apps/plugins-workspace/pull/2522) by [@adrieljss](https://github.com/tauri-apps/plugins-workspace/../../adrieljss)) Fix `fetch` blocking until the whole response is read even if it was a streaming response. + +## \[2.4.0] + +- [`cb38f54f`](https://github.com/tauri-apps/plugins-workspace/commit/cb38f54f4a4ef30995283cd82166c62da17bac44) ([#2479](https://github.com/tauri-apps/plugins-workspace/pull/2479) by [@adrieljss](https://github.com/tauri-apps/plugins-workspace/../../adrieljss)) Add stream support for HTTP stream responses. + +## \[2.3.0] + +- [`10513649`](https://github.com/tauri-apps/plugins-workspace/commit/105136494c5a5bf4b1f1cc06cc71815412d17ec8) ([#2204](https://github.com/tauri-apps/plugins-workspace/pull/2204) by [@RickeyWard](https://github.com/tauri-apps/plugins-workspace/../../RickeyWard)) Add `dangerous-settings` feature flag and new JS `danger` option to disable tls hostname/certificate validation. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +### Dependencies + +- Upgraded to `fs@2.2.0` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `fs-js@2.0.4` + +## \[2.0.4] + +- [`a3b553dd`](https://github.com/tauri-apps/plugins-workspace/commit/a3b553ddb403771aa699362c4e69a064b7731da5) ([#2079](https://github.com/tauri-apps/plugins-workspace/pull/2079) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add tracing logs for requestes and responses behind `tracing` feature flag. + +### Dependencies + +- Upgraded to `fs@2.1.0` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `fs@2.0.3` + +## \[2.0.1] + +- [`cfd48b3b`](https://github.com/tauri-apps/plugins-workspace/commit/cfd48b3b2ec0fccfc162197518694ed59ceda22c) ([#1941](https://github.com/tauri-apps/plugins-workspace/pull/1941) by [@Nipsuli](https://github.com/tauri-apps/plugins-workspace/../../Nipsuli)) Allow skipping sending `Origin` header in HTTP requests by setting `Origin` header to an empty string when calling `fetch`. +- [`9b2840db`](https://github.com/tauri-apps/plugins-workspace/commit/9b2840db9464cf08db75806270e4441f0af81e5d) ([#1884](https://github.com/tauri-apps/plugins-workspace/pull/1884) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Retain headers order. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `fs@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `fs@2.0.0` + +## \[2.0.0-rc.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.6` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.5` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.4` + +## \[2.0.0-rc.3] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.3` + +## \[2.0.0-rc.2] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.2` + +## \[2.0.0-rc.2] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`84f8bd5e`](https://github.com/tauri-apps/plugins-workspace/commit/84f8bd5e1ef22e664267380b5bbf756ebc5389c3) ([#1662](https://github.com/tauri-apps/plugins-workspace/pull/1662) by [@twlite](https://github.com/tauri-apps/plugins-workspace/../../twlite)) Fixed an issue with abort signal not aborting the fetch request. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.0` + +## \[2.0.0-beta.9] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.8] + +- [`ac9a25cc`](https://github.com/tauri-apps/plugins-workspace/commit/ac9a25cc12ee2b325f00212ba74316da3369bde5) ([#1395](https://github.com/tauri-apps/plugins-workspace/pull/1395) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix cancelling requests using `AbortSignal`. +- [`a6654932`](https://github.com/tauri-apps/plugins-workspace/commit/a66549329c60dea35e3a06a38c357e368c9053a1) ([#1526](https://github.com/tauri-apps/plugins-workspace/pull/1526) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix missing `Set-Cookie` headers in the response which meant `request.headers.getSetCookie()` always returned empty array. +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.7] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.6] + +- [`0f739dbc`](https://github.com/tauri-apps/plugins-workspace/commit/0f739dbc483a1f091977cbe575c3862fd39f8cf1) ([#1392](https://github.com/tauri-apps/plugins-workspace/pull/1392) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Allow setting `Origin` header when `unsafe-headers` feature flag is active. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`9d7ae45b`](https://github.com/tauri-apps/plugins-workspace/commit/9d7ae45b0edf9b22c73e7d7c413a784bb35c3d77)([#1354](https://github.com/tauri-apps/plugins-workspace/pull/1354)) Include headers created by browser if not declared by user, which fixes missing headers like `Content-Type` when using `FormData`. +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.6` + +## \[2.0.0-beta.5] + +- [`500ff10`](https://github.com/tauri-apps/plugins-workspace/commit/500ff10fbd89fdfc73caf9d153029dad567b4ff1)([#1166](https://github.com/tauri-apps/plugins-workspace/pull/1166)) **Breaking change:** Removed the `default-tls` feature flag. The `rustls-tls`, `http2`, `macos-system-configuration`, and `charset` feature flags are now enabled by default. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Internally use the webview scoped resources table instead of the app one, so other webviews can't access other webviews resources. +- [`7e2fcc5`](https://github.com/tauri-apps/plugins-workspace/commit/7e2fcc5e74df7c3c718e40f75bfb0eafc7d69d8d)([#1146](https://github.com/tauri-apps/plugins-workspace/pull/1146)) Update dependencies to align with tauri 2.0.0-beta.14. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Update for tauri 2.0.0-beta.15. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.5` + +## \[2.0.0-beta.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.4` + +## \[2.0.0-beta.3] + +- [`c873e4d`](https://github.com/tauri-apps/plugins-workspace/commit/c873e4d6c74e759742f7c9a88e35cff10a75122a)([#1059](https://github.com/tauri-apps/plugins-workspace/pull/1059)) Fixes scope not allowing subpaths, query parameters and hash when those values are empty. +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. +- [`753c7be`](https://github.com/tauri-apps/plugins-workspace/commit/753c7be0a6a78121d2e88ea0efc3040580c885b4)([#1050](https://github.com/tauri-apps/plugins-workspace/pull/1050)) Add `unsafe-headers` cargo feature flag to allow using [forbidden headers](https://fetch.spec.whatwg.org/#terminology-headers). + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.3` + +## \[2.0.0-beta.2] + +- [`ae56b13`](https://github.com/tauri-apps/plugins-workspace/commit/ae56b13a4d49dbf922b8a0fbb0d557bb63c1d72b)([#983](https://github.com/tauri-apps/plugins-workspace/pull/983)) Allow `User-Agent` header to be set. +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`1a34720`](https://github.com/tauri-apps/plugins-workspace/commit/1a347203a54eccc954749d11c4ee81fdd9a0cde7)([#858](https://github.com/tauri-apps/plugins-workspace/pull/858)) Fix http fetch client option init with parameter `connectTimeout` + +## \[2.0.0-alpha.9] + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.7` + +## \[2.0.0-alpha.6] + +- [`bfa87da`](https://github.com/tauri-apps/plugins-workspace/commit/bfa87da848f9f1da2abae3354eed632881eddf11)([#824](https://github.com/tauri-apps/plugins-workspace/pull/824)) Add `proxy` field to `fetch` options to configure proxy. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.3] + +- [`2cb0fa7`](https://github.com/tauri-apps/plugins-workspace/commit/2cb0fa719b8b1f5ac07dada93520dbbcf637d64c)([#587](https://github.com/tauri-apps/plugins-workspace/pull/587)) Remove `cmd` property which breaks invoke call. +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.2` + +## \[2.0.0-alpha.2] + +- [`aec17a9`](https://github.com/tauri-apps/plugins-workspace/commit/aec17a90fc365774c70c4876b94a899416120e26)([#558](https://github.com/tauri-apps/plugins-workspace/pull/558)) Improve response performance by using the new IPC streaming data. + +## \[2.0.0-alpha.1] + +- [`7d9df72`](https://github.com/tauri-apps/plugins-workspace/commit/7d9df7297a221a64d9de945ffc2cd8313d3104dc)([#428](https://github.com/tauri-apps/plugins-workspace/pull/428)) Multipart requests are now handled in JavaScript by the `Request` JavaScript class so you just need to use a `FormData` body and not set the content-type header to `multipart/form-data`. `application/x-www-form-urlencoded` requests must be done manually. +- [`7d9df72`](https://github.com/tauri-apps/plugins-workspace/commit/7d9df7297a221a64d9de945ffc2cd8313d3104dc)([#428](https://github.com/tauri-apps/plugins-workspace/pull/428)) The http plugin has been rewritten from scratch and now only exposes a `fetch` function in Javascript and Re-exports `reqwest` crate in Rust. The new `fetch` method tries to be as close and compliant to the `fetch` Web API as possible. +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml new file mode 100644 index 0000000..f0eeadd --- /dev/null +++ b/plugins/http/Cargo.toml @@ -0,0 +1,80 @@ +[package] +name = "tauri-plugin-http" +version = "2.7.0" +description = "Access an HTTP client written in Rust." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-http" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } +schemars = { workspace = true } +serde = { workspace = true } +url = { workspace = true } +urlpattern = "0.3" +regex = "1" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +thiserror = { workspace = true } +tokio = { version = "1", features = ["sync", "macros"] } +tauri-plugin-fs = { path = "../fs", version = "2.5.2" } +urlpattern = "0.3" +regex = "1" +http = "1" +# 0.12.16 is when `system-proxy` was added +reqwest = { version = "0.12.16", default-features = false } +url = { workspace = true } +data-url = "0.3" +cookie_store = { version = "0.22", optional = true, features = ["serde"] } +bytes = { version = "1.9", optional = true } +tracing = { workspace = true, optional = true } + +[features] +default = ["rustls-tls", "http2", "charset", "system-proxy", "cookies"] +multipart = ["reqwest/multipart"] +json = ["reqwest/json"] +stream = ["reqwest/stream"] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +native-tls-alpn = ["reqwest/native-tls-alpn"] +rustls-tls = ["reqwest/rustls-tls"] +rustls-tls-manual-roots = ["reqwest/rustls-tls-manual-roots"] +rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] +rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] +blocking = ["reqwest/blocking"] +cookies = ["reqwest/cookies", "dep:cookie_store", "dep:bytes"] +gzip = ["reqwest/gzip"] +brotli = ["reqwest/brotli"] +deflate = ["reqwest/deflate"] +zstd = ["reqwest/zstd"] +trust-dns = ["reqwest/trust-dns"] +socks = ["reqwest/socks"] +http2 = ["reqwest/http2"] +charset = ["reqwest/charset"] +# Deprecated, switch to system-proxy +macos-system-configuration = ["reqwest/macos-system-configuration"] +system-proxy = ["reqwest/system-proxy"] + +unsafe-headers = [] +tracing = ["dep:tracing"] +dangerous-settings = [] diff --git a/plugins/http/LICENSE.spdx b/plugins/http/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/http/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/http/LICENSE_APACHE-2.0 b/plugins/http/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/http/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/http/LICENSE_MIT b/plugins/http/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/http/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/http/README.md b/plugins/http/README.md new file mode 100644 index 0000000..7b8e3d2 --- /dev/null +++ b/plugins/http/README.md @@ -0,0 +1,93 @@ +![plugin-http](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/http/banner.png) + +Access the HTTP client written in Rust. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-http = "2.0.0" +# alternatively with Git: +tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-http +# or +npm add @tauri-apps/plugin-http +# or +yarn add @tauri-apps/plugin-http +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { fetch } from '@tauri-apps/plugin-http' +const response = await fetch('http://localhost:3003/users/2', { + method: 'GET', + connectTimeout: 30 +}) +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/http/SECURITY.md b/plugins/http/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/http/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/http/api-iife.js b/plugins/http/api-iife.js new file mode 100644 index 0000000..5d3720a --- /dev/null +++ b/plugins/http/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e,t){"use strict";const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,i=n?.connectTimeout,s=n?.proxy,c=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const d=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,l=new Request(e,n),u=await l.arrayBuffer(),h=0!==u.byteLength?Array.from(new Uint8Array(u)):null;for(const[e,t]of l.headers)d.get(e)||d.set(e,t);const _=(d instanceof Headers?Array.from(d.entries()):Array.isArray(d)?d:Object.entries(d)).map(([e,t])=>[e,"string"==typeof t?t:t.toString()]);if(a?.aborted)throw new Error(r);const w=await t.invoke("plugin:http|fetch",{clientConfig:{method:l.method,url:l.url,headers:_,data:h,maxRedirections:o,connectTimeout:i,proxy:s,danger:c}}),f=()=>t.invoke("plugin:http|fetch_cancel",{rid:w}).catch(()=>{});if(a?.aborted)throw f(),new Error(r);a?.addEventListener("abort",()=>{f()});const{status:y,statusText:p,url:b,headers:g,rid:v}=await t.invoke("plugin:http|fetch_send",{rid:w});let T=!1;const m=()=>T?Promise.resolve():(T=!0,t.invoke("plugin:http|fetch_cancel_body",{rid:v}).catch(()=>{})),R=[101,103,204,205,304].includes(y)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",()=>{e.error(r),m()})},pull:e=>(async e=>{let r;try{r=await t.invoke("plugin:http|fetch_read_body",{rid:v})}catch(t){return e.error(t),void m()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e),cancel:()=>{m()}}),A=new Response(R,{status:y,statusText:p});Object.defineProperty(A,"url",{value:b,writable:!1}),Object.defineProperty(A,"headers",{value:new Headers(g),writable:!1});const P=A.clone.bind(A);return Object.defineProperty(A,"clone",{value:()=>{const e=P();return Object.defineProperty(e,"url",{value:b,writable:!1}),Object.defineProperty(e,"headers",{value:new Headers(g),writable:!1}),e}}),A},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} diff --git a/plugins/http/banner.png b/plugins/http/banner.png new file mode 100644 index 0000000..121741e Binary files /dev/null and b/plugins/http/banner.png differ diff --git a/plugins/http/build.rs b/plugins/http/build.rs new file mode 100644 index 0000000..31fa023 --- /dev/null +++ b/plugins/http/build.rs @@ -0,0 +1,73 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[path = "src/scope.rs"] +#[allow(dead_code)] +mod scope; + +const COMMANDS: &[&str] = &[ + "fetch", + "fetch_cancel", + "fetch_send", + "fetch_read_body", + "fetch_cancel_body", +]; + +/// HTTP scope entry. +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +#[allow(unused)] +enum HttpScopeEntry { + /// A URL that can be accessed by the webview when using the HTTP APIs. + /// Wildcards can be used following the URL pattern standard. + /// + /// See [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information. + /// + /// Examples: + /// + /// - "https://*" : allows all HTTPS origin on port 443 + /// + /// - "https://*:*" : allows all HTTPS origin on any port + /// + /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path + /// + /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" + Value(String), + Object { + /// A URL that can be accessed by the webview when using the HTTP APIs. + /// Wildcards can be used following the URL pattern standard. + /// + /// See [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information. + /// + /// Examples: + /// + /// - "https://*" : allows all HTTPS origin on port 443 + /// + /// - "https://*:*" : allows all HTTPS origin on any port + /// + /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path + /// + /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" + url: String, + }, +} + +// Ensure `HttpScopeEntry` and `scope::EntryRaw` is kept in sync +fn _f() { + match scope::EntryRaw::Value(String::new()) { + scope::EntryRaw::Value(url) => HttpScopeEntry::Value(url), + scope::EntryRaw::Object { url } => HttpScopeEntry::Object { url }, + }; + match HttpScopeEntry::Value(String::new()) { + HttpScopeEntry::Value(url) => scope::EntryRaw::Value(url), + HttpScopeEntry::Object { url } => scope::EntryRaw::Object { url }, + }; +} + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .global_scope_schema(schemars::schema_for!(HttpScopeEntry)) + .build(); +} diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts new file mode 100644 index 0000000..5d4e094 --- /dev/null +++ b/plugins/http/guest-js/index.ts @@ -0,0 +1,347 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Make HTTP requests with the Rust backend. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the URLs that can be accessed using glob patterns. + * + * For instance, this scope configuration only allows making HTTP requests to all subdomains for `tauri.app` except for `https://private.tauri.app`: + * ```json + * { + * "permissions": [ + * { + * "identifier": "http:default", + * "allow": [{ "url": "https://*.tauri.app" }], + * "deny": [{ "url": "https://private.tauri.app" }] + * } + * ] + * } + * ``` + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * Configuration of a proxy that a Client should pass requests to. + * + * @since 2.0.0 + */ +export interface Proxy { + /** + * Proxy all traffic to the passed URL. + */ + all?: string | ProxyConfig + /** + * Proxy all HTTP traffic to the passed URL. + */ + http?: string | ProxyConfig + /** + * Proxy all HTTPS traffic to the passed URL. + */ + https?: string | ProxyConfig +} + +/** + * Detailed configuration of a single proxy server, used when a plain URL string is not enough. + * + * @since 2.0.0 + */ +export interface ProxyConfig { + /** + * The URL of the proxy server. + */ + url: string + /** + * Set the `Proxy-Authorization` header using Basic auth. + */ + basicAuth?: { + /** + * The user name sent to the proxy server. + */ + username: string + /** + * The password sent to the proxy server. + */ + password: string + } + /** + * A configuration for filtering out requests that shouldn't be proxied. + * Entries are expected to be comma-separated (whitespace between entries is ignored) + */ + noProxy?: string +} + +/** + * Options to configure the Rust client used to make fetch requests + * + * @since 2.0.0 + */ +export interface ClientOptions { + /** + * Defines the maximum number of redirects the client should follow. + * If set to 0, no redirects will be followed. + * + * When the `scopeRedirects` plugin configuration is enabled, every redirect must + * also be allowed by the configured scope, otherwise the request fails + * instead of being followed. + */ + maxRedirections?: number + /** Timeout in milliseconds */ + connectTimeout?: number + /** + * Configuration of a proxy that a Client should pass requests to. + */ + proxy?: Proxy + /** + * Configuration for dangerous settings on the client such as disabling SSL verification. + */ + danger?: DangerousSettings +} + +/** + * Configuration for dangerous settings on the client such as disabling SSL verification. + * + * @since 2.3.0 + */ +export interface DangerousSettings { + /** + * Disables SSL verification. + */ + acceptInvalidCerts?: boolean + /** + * Disables hostname verification. + */ + acceptInvalidHostnames?: boolean +} + +const ERROR_REQUEST_CANCELLED = 'Request cancelled' + +/** + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. + * + * The request is performed by the Rust backend instead of the webview, so it is not subject to + * CORS, but the URL must be allowed by the plugin scope. + * + * @example + * ```typescript + * import { fetch } from '@tauri-apps/plugin-http'; + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` + * + * @param input The resource to fetch, as a URL, a string or a `Request` object. + * @param init The standard `fetch` request options, extended with the Rust client options from + * {@linkcode ClientOptions}: `maxRedirections`, `connectTimeout`, `proxy` and `danger`. The + * `signal` option can be used to abort the request. + * @returns A promise resolving to the `Response` of the request. + * + * @since 2.0.0 + */ +export async function fetch( + input: URL | Request | string, + init?: RequestInit & ClientOptions +): Promise { + // Optimistically check for abort signal and avoid doing any work + const signal = init?.signal + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED) + } + + const maxRedirections = init?.maxRedirections + const connectTimeout = init?.connectTimeout + const proxy = init?.proxy + const danger = init?.danger + + // Remove these fields before creating the request + if (init) { + delete init.maxRedirections + delete init.connectTimeout + delete init.proxy + delete init.danger + } + + const headers = init?.headers + ? init.headers instanceof Headers + ? init.headers + : new Headers(init.headers) + : new Headers() + + const req = new Request(input, init) + const buffer = await req.arrayBuffer() + const data = + buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null + + // append new headers created by the browser `Request` implementation, + // if not already declared by the caller of this function + for (const [key, value] of req.headers) { + if (!headers.get(key)) { + headers.set(key, value) + } + } + + const headersArray = + headers instanceof Headers + ? Array.from(headers.entries()) + : Array.isArray(headers) + ? headers + : Object.entries(headers) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mappedHeaders: Array<[string, string]> = headersArray.map( + ([name, val]) => [ + name, + // we need to ensure we have all header values as strings + // eslint-disable-next-line + typeof val === 'string' ? val : (val as any).toString() + ] + ) + + // Optimistically check for abort signal and avoid doing any work on the Rust side + if (signal?.aborted) { + throw new Error(ERROR_REQUEST_CANCELLED) + } + + const rid = await invoke('plugin:http|fetch', { + clientConfig: { + method: req.method, + url: req.url, + headers: mappedHeaders, + data, + maxRedirections, + connectTimeout, + proxy, + danger + } + }) + + const abort = () => + invoke('plugin:http|fetch_cancel', { rid }).catch(() => {}) + + // Optimistically check for abort signal + // and avoid doing any work after doing intial work on the Rust side + if (signal?.aborted) { + // we don't care about the result of this promise + void abort() + throw new Error(ERROR_REQUEST_CANCELLED) + } + + signal?.addEventListener('abort', () => void abort()) + + interface FetchSendResponse { + status: number + statusText: string + headers: [[string, string]] + url: string + rid: number + } + + const { + status, + statusText, + url, + headers: responseHeaders, + rid: responseRid + } = await invoke('plugin:http|fetch_send', { + rid + }) + + let bodyDropped = false + const dropBody = () => { + if (bodyDropped) return Promise.resolve() + bodyDropped = true + return invoke('plugin:http|fetch_cancel_body', { rid: responseRid }).catch( + () => {} + ) + } + + const readChunk = async ( + controller: ReadableStreamDefaultController + ) => { + let data: ArrayBuffer + try { + data = await invoke('plugin:http|fetch_read_body', { + rid: responseRid + }) + } catch (e) { + // close the stream if an error occurs + // and drop the body on Rust side + controller.error(e) + void dropBody() + return + } + + const dataUint8 = new Uint8Array(data) + const lastByte = dataUint8[dataUint8.byteLength - 1] + const actualData = dataUint8.slice(0, dataUint8.byteLength - 1) + + // close when the signal to close (last byte is 1) is sent from the IPC. + if (lastByte === 1) { + controller.close() + return + } + + controller.enqueue(actualData) + } + + // no body for 101, 103, 204, 205 and 304 + // see https://fetch.spec.whatwg.org/#null-body-status + const body = [101, 103, 204, 205, 304].includes(status) + ? null + : new ReadableStream({ + start: (controller) => { + // listen for abort events to cancel reading + signal?.addEventListener('abort', () => { + controller.error(ERROR_REQUEST_CANCELLED) + void dropBody() + }) + }, + pull: (controller) => readChunk(controller), + cancel: () => { + // Ensure body resources are released on stream cancellation + void dropBody() + } + }) + + const res = new Response(body, { + status, + statusText + }) + + // `Response.url` cannot be set via the constructor, so we define it manually + Object.defineProperty(res, 'url', { value: url, writable: false }) + + // Expose `set-cookie` via `response.headers` (and `getSetCookie()` where + // supported). This is not Fetch-spec compliant for network responses in + // browsers, where `set-cookie` is treated as a forbidden response + // header and is generally not readable from JavaScript. + Object.defineProperty(res, 'headers', { + value: new Headers(responseHeaders), + writable: false + }) + + // Patch clone() per-instance so cloning preserves the overridden properties + const originalClone = res.clone.bind(res) + Object.defineProperty(res, 'clone', { + value: () => { + const cloned = originalClone() + Object.defineProperty(cloned, 'url', { value: url, writable: false }) + Object.defineProperty(cloned, 'headers', { + value: new Headers(responseHeaders), + writable: false + }) + return cloned + } + }) + + return res +} diff --git a/plugins/http/package.json b/plugins/http/package.json new file mode 100644 index 0000000..5d612e6 --- /dev/null +++ b/plugins/http/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-http", + "version": "2.6.1", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/http/permissions/autogenerated/commands/fetch.toml b/plugins/http/permissions/autogenerated/commands/fetch.toml new file mode 100644 index 0000000..c4e068a --- /dev/null +++ b/plugins/http/permissions/autogenerated/commands/fetch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch" +description = "Enables the fetch command without any pre-configured scope." +commands.allow = ["fetch"] + +[[permission]] +identifier = "deny-fetch" +description = "Denies the fetch command without any pre-configured scope." +commands.deny = ["fetch"] diff --git a/plugins/http/permissions/autogenerated/commands/fetch_cancel.toml b/plugins/http/permissions/autogenerated/commands/fetch_cancel.toml new file mode 100644 index 0000000..70c1139 --- /dev/null +++ b/plugins/http/permissions/autogenerated/commands/fetch_cancel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-cancel" +description = "Enables the fetch_cancel command without any pre-configured scope." +commands.allow = ["fetch_cancel"] + +[[permission]] +identifier = "deny-fetch-cancel" +description = "Denies the fetch_cancel command without any pre-configured scope." +commands.deny = ["fetch_cancel"] diff --git a/plugins/http/permissions/autogenerated/commands/fetch_cancel_body.toml b/plugins/http/permissions/autogenerated/commands/fetch_cancel_body.toml new file mode 100644 index 0000000..19e6652 --- /dev/null +++ b/plugins/http/permissions/autogenerated/commands/fetch_cancel_body.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-cancel-body" +description = "Enables the fetch_cancel_body command without any pre-configured scope." +commands.allow = ["fetch_cancel_body"] + +[[permission]] +identifier = "deny-fetch-cancel-body" +description = "Denies the fetch_cancel_body command without any pre-configured scope." +commands.deny = ["fetch_cancel_body"] diff --git a/plugins/http/permissions/autogenerated/commands/fetch_read_body.toml b/plugins/http/permissions/autogenerated/commands/fetch_read_body.toml new file mode 100644 index 0000000..b5867e9 --- /dev/null +++ b/plugins/http/permissions/autogenerated/commands/fetch_read_body.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-read-body" +description = "Enables the fetch_read_body command without any pre-configured scope." +commands.allow = ["fetch_read_body"] + +[[permission]] +identifier = "deny-fetch-read-body" +description = "Denies the fetch_read_body command without any pre-configured scope." +commands.deny = ["fetch_read_body"] diff --git a/plugins/http/permissions/autogenerated/commands/fetch_send.toml b/plugins/http/permissions/autogenerated/commands/fetch_send.toml new file mode 100644 index 0000000..f6a55d2 --- /dev/null +++ b/plugins/http/permissions/autogenerated/commands/fetch_send.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-fetch-send" +description = "Enables the fetch_send command without any pre-configured scope." +commands.allow = ["fetch_send"] + +[[permission]] +identifier = "deny-fetch-send" +description = "Denies the fetch_send command without any pre-configured scope." +commands.deny = ["fetch_send"] diff --git a/plugins/http/permissions/autogenerated/reference.md b/plugins/http/permissions/autogenerated/reference.md new file mode 100644 index 0000000..2f39e74 --- /dev/null +++ b/plugins/http/permissions/autogenerated/reference.md @@ -0,0 +1,160 @@ +## Default Permission + +This permission set configures what kind of +fetch operations are available from the http plugin. + +This enables all fetch operations but does not +allow explicitly any origins to be fetched. This needs to +be manually configured before usage. + +#### Granted Permissions + +All fetch operations are enabled. + +#### This default permission set includes the following: + +- `allow-fetch` +- `allow-fetch-cancel` +- `allow-fetch-send` +- `allow-fetch-read-body` +- `allow-fetch-cancel-body` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`http:allow-fetch` + + + +Enables the fetch command without any pre-configured scope. + +
+ +`http:deny-fetch` + + + +Denies the fetch command without any pre-configured scope. + +
+ +`http:allow-fetch-cancel` + + + +Enables the fetch_cancel command without any pre-configured scope. + +
+ +`http:deny-fetch-cancel` + + + +Denies the fetch_cancel command without any pre-configured scope. + +
+ +`http:allow-fetch-cancel-body` + + + +Enables the fetch_cancel_body command without any pre-configured scope. + +
+ +`http:deny-fetch-cancel-body` + + + +Denies the fetch_cancel_body command without any pre-configured scope. + +
+ +`http:allow-fetch-read-body` + + + +Enables the fetch_read_body command without any pre-configured scope. + +
+ +`http:deny-fetch-read-body` + + + +Denies the fetch_read_body command without any pre-configured scope. + +
+ +`http:allow-fetch-send` + + + +Enables the fetch_send command without any pre-configured scope. + +
+ +`http:deny-fetch-send` + + + +Denies the fetch_send command without any pre-configured scope. + +
diff --git a/plugins/http/permissions/default.toml b/plugins/http/permissions/default.toml new file mode 100644 index 0000000..541bf49 --- /dev/null +++ b/plugins/http/permissions/default.toml @@ -0,0 +1,23 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures what kind of +fetch operations are available from the http plugin. + +This enables all fetch operations but does not +allow explicitly any origins to be fetched. This needs to +be manually configured before usage. + +#### Granted Permissions + +All fetch operations are enabled. + +""" +permissions = [ + "allow-fetch", + "allow-fetch-cancel", + "allow-fetch-send", + "allow-fetch-read-body", + "allow-fetch-cancel-body", +] diff --git a/plugins/http/permissions/schemas/schema.json b/plugins/http/permissions/schemas/schema.json new file mode 100644 index 0000000..a6b457b --- /dev/null +++ b/plugins/http/permissions/schemas/schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the fetch command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch", + "markdownDescription": "Enables the fetch command without any pre-configured scope." + }, + { + "description": "Denies the fetch command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch", + "markdownDescription": "Denies the fetch command without any pre-configured scope." + }, + { + "description": "Enables the fetch_cancel command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-cancel", + "markdownDescription": "Enables the fetch_cancel command without any pre-configured scope." + }, + { + "description": "Denies the fetch_cancel command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-cancel", + "markdownDescription": "Denies the fetch_cancel command without any pre-configured scope." + }, + { + "description": "Enables the fetch_cancel_body command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-cancel-body", + "markdownDescription": "Enables the fetch_cancel_body command without any pre-configured scope." + }, + { + "description": "Denies the fetch_cancel_body command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-cancel-body", + "markdownDescription": "Denies the fetch_cancel_body command without any pre-configured scope." + }, + { + "description": "Enables the fetch_read_body command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-read-body", + "markdownDescription": "Enables the fetch_read_body command without any pre-configured scope." + }, + { + "description": "Denies the fetch_read_body command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-read-body", + "markdownDescription": "Denies the fetch_read_body command without any pre-configured scope." + }, + { + "description": "Enables the fetch_send command without any pre-configured scope.", + "type": "string", + "const": "allow-fetch-send", + "markdownDescription": "Enables the fetch_send command without any pre-configured scope." + }, + { + "description": "Denies the fetch_send command without any pre-configured scope.", + "type": "string", + "const": "deny-fetch-send", + "markdownDescription": "Denies the fetch_send command without any pre-configured scope." + }, + { + "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/http/rollup.config.js b/plugins/http/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/http/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs new file mode 100644 index 0000000..4387bae --- /dev/null +++ b/plugins/http/src/commands.rs @@ -0,0 +1,718 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration}; + +use http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; +use reqwest::{redirect::Policy, NoProxy}; +use serde::{Deserialize, Serialize}; +use tauri::{ + async_runtime::Mutex, + command, + ipc::{CommandScope, GlobalScope}, + Manager, ResourceId, ResourceTable, Runtime, State, Webview, +}; +use tokio::sync::oneshot::{channel, Receiver, Sender}; + +use crate::{ + scope::{Entry, Scope}, + Error, Http, Result, +}; + +const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +/// Matches the default [`reqwest`] redirect policy, used when the frontend does not +/// configure `maxRedirections`. +const DEFAULT_MAX_REDIRECTIONS: usize = 10; + +struct ReqwestResponse(reqwest::Response); +impl tauri::Resource for ReqwestResponse {} + +type CancelableResponseResult = Result; +type CancelableResponseFuture = + Pin + Send + Sync>>; + +struct FetchRequest { + fut: Mutex, + abort_tx_rid: ResourceId, + abort_rx_rid: ResourceId, +} +impl tauri::Resource for FetchRequest {} + +struct AbortSender(Sender<()>); +impl tauri::Resource for AbortRecveiver {} + +impl AbortSender { + fn abort(self) { + let _ = self.0.send(()); + } +} + +struct AbortRecveiver(Receiver<()>); +impl tauri::Resource for AbortSender {} + +trait AddRequest { + fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId; +} + +impl AddRequest for ResourceTable { + fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId { + let (tx, rx) = channel::<()>(); + let (tx, rx) = (AbortSender(tx), AbortRecveiver(rx)); + let req = FetchRequest { + fut: Mutex::new(fut), + abort_tx_rid: self.add(tx), + abort_rx_rid: self.add(rx), + }; + self.add(req) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponse { + status: u16, + status_text: String, + headers: Vec<(String, String)>, + url: String, + rid: ResourceId, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] //feature flags shoudln't affect api +pub struct DangerousSettings { + accept_invalid_certs: bool, + accept_invalid_hostnames: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientConfig { + method: String, + url: url::Url, + headers: Vec<(String, String)>, + data: Option>, + connect_timeout: Option, + max_redirections: Option, + proxy: Option, + danger: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Proxy { + all: Option, + http: Option, + https: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +pub enum UrlOrConfig { + Url(String), + Config(ProxyConfig), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyConfig { + url: String, + basic_auth: Option, + no_proxy: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BasicAuth { + username: String, + password: String, +} + +#[inline] +fn proxy_creator( + url_or_config: UrlOrConfig, + proxy_fn: fn(String) -> reqwest::Result, +) -> reqwest::Result { + match url_or_config { + UrlOrConfig::Url(url) => Ok(proxy_fn(url)?), + UrlOrConfig::Config(ProxyConfig { + url, + basic_auth, + no_proxy, + }) => { + let mut proxy = proxy_fn(url)?; + if let Some(basic_auth) = basic_auth { + proxy = proxy.basic_auth(&basic_auth.username, &basic_auth.password); + } + if let Some(no_proxy) = no_proxy { + proxy = proxy.no_proxy(NoProxy::from_string(&no_proxy)); + } + Ok(proxy) + } + } +} + +fn attach_proxy( + proxy: Proxy, + mut builder: reqwest::ClientBuilder, +) -> crate::Result { + let Proxy { all, http, https } = proxy; + + if let Some(all) = all { + let proxy = proxy_creator(all, reqwest::Proxy::all)?; + builder = builder.proxy(proxy); + } + + if let Some(http) = http { + let proxy = proxy_creator(http, reqwest::Proxy::http)?; + builder = builder.proxy(proxy); + } + + if let Some(https) = https { + let proxy = proxy_creator(https, reqwest::Proxy::https)?; + builder = builder.proxy(proxy); + } + + Ok(builder) +} + +/// Builds the redirect policy for the request. +/// +/// When `scope` is [`Some`], **every** hop is checked against it. Validating only the URL the +/// frontend asked for is not enough: a server on an allowed origin can answer with a redirect to +/// any other origin (an open redirect, or a server the attacker controls), and following it would +/// give the webview access to a URL the scope denies. +/// +/// That check is opt-in through the `scopeRedirects` configuration because it breaks applications +/// that rely on being redirected outside of their scope. +// TODO(v3): always check the scope and take it by value instead of `Option` +fn redirect_policy(scope: Option, max_redirections: Option) -> Policy { + if max_redirections == Some(0) { + return Policy::none(); + } + + let max_redirections = max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS); + + let Some(scope) = scope else { + return Policy::limited(max_redirections); + }; + + Policy::custom(move |attempt| { + // the first URL in `previous` is the initial request, so it must be excluded + if attempt.previous().len() > max_redirections { + attempt.error("too many redirects") + } else if scope.is_allowed(attempt.url()) { + attempt.follow() + } else { + let url = attempt.url().clone(); + attempt.error(Error::UrlNotAllowed(url)) + } + }) +} + +/// [`reqwest`] wraps the error returned by the redirect policy, and its `Display` impl does not +/// include the source, so we unwrap our own scope error to keep the reason visible to the frontend. +fn map_request_error(error: reqwest::Error) -> Error { + if error.is_redirect() { + let mut source = std::error::Error::source(&error); + while let Some(err) = source { + if let Some(Error::UrlNotAllowed(url)) = err.downcast_ref::() { + return Error::UrlNotAllowed(url.clone()); + } + source = err.source(); + } + } + + Error::Network(error) +} + +#[command] +pub async fn fetch( + webview: Webview, + state: State<'_, Http>, + client_config: ClientConfig, + command_scope: CommandScope, + global_scope: GlobalScope, +) -> crate::Result { + let ClientConfig { + method, + url, + headers: headers_raw, + data, + connect_timeout, + max_redirections, + proxy, + danger, + } = client_config; + + let scheme = url.scheme(); + let method = Method::from_bytes(method.as_bytes())?; + + let mut headers = HeaderMap::new(); + for (h, v) in headers_raw { + let name = HeaderName::from_str(&h)?; + #[cfg(not(feature = "unsafe-headers"))] + if is_unsafe_header(&name) { + #[cfg(debug_assertions)] + { + eprintln!("[\x1b[33mWARNING\x1b[0m] Skipping {name} header as it is a forbidden header per fetch spec https://fetch.spec.whatwg.org/#terminology-headers"); + eprintln!("[\x1b[33mWARNING\x1b[0m] if keeping the header is a desired behavior, you can enable `unsafe-headers` feature flag in your Cargo.toml"); + } + continue; + } + + headers.append(name, HeaderValue::from_str(&v)?); + } + + match scheme { + "http" | "https" => { + let scope = Scope::new( + command_scope + .allows() + .iter() + .chain(global_scope.allows()) + .cloned() + .collect(), + command_scope + .denies() + .iter() + .chain(global_scope.denies()) + .cloned() + .collect(), + ); + + if !scope.is_allowed(&url) { + return Err(Error::UrlNotAllowed(url)); + } + + let mut builder = reqwest::ClientBuilder::new(); + + if let Some(danger_config) = danger { + #[cfg(not(feature = "dangerous-settings"))] + { + #[cfg(debug_assertions)] + { + eprintln!("[\x1b[33mWARNING\x1b[0m] using dangerous settings requires `dangerous-settings` feature flag in your Cargo.toml"); + } + let _ = danger_config; + return Err(Error::DangerousSettings); + } + #[cfg(feature = "dangerous-settings")] + { + builder = builder + .danger_accept_invalid_certs(danger_config.accept_invalid_certs) + .danger_accept_invalid_hostnames(danger_config.accept_invalid_hostnames) + } + } + + if let Some(timeout) = connect_timeout { + builder = builder.connect_timeout(Duration::from_millis(timeout)); + } + + let scope = state.config.scope_redirects.then_some(scope); + builder = builder.redirect(redirect_policy(scope, max_redirections)); + + if let Some(proxy_config) = proxy { + builder = attach_proxy(proxy_config, builder)?; + } + + #[cfg(feature = "cookies")] + { + builder = builder.cookie_provider(state.cookies_jar.clone()); + } + + let mut request = builder.build()?.request(method.clone(), url); + + // POST and PUT requests should always have a 0 length content-length, + // if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch + if data.is_none() && matches!(method, Method::POST | Method::PUT) { + headers.append(header::CONTENT_LENGTH, HeaderValue::from_str("0")?); + } + + if headers.contains_key(header::RANGE) { + // https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18 + // If httpRequest's header list contains `Range`, then append (`Accept-Encoding`, `identity`) + headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?); + } + + if !headers.contains_key(header::USER_AGENT) { + headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?); + } + + // ensure we have an Origin header set + if cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN) { + if let Ok(url) = webview.url() { + // The url crate returns OpaqueOrigin for tauri://localhost which serializes to "null" + let origin = if url.scheme() == "tauri" { + "tauri://localhost".to_string() + } else { + url.origin().ascii_serialization() + }; + headers.append(header::ORIGIN, HeaderValue::from_str(&origin)?); + } + } + + // In case empty origin is passed, remove it. Some services do not like Origin header + // so this way we can remove it in explicit way. The default behaviour is still to set it + if cfg!(feature = "unsafe-headers") + && headers.get(header::ORIGIN) == Some(&HeaderValue::from_static("")) + { + headers.remove(header::ORIGIN); + }; + + if let Some(data) = data { + request = request.body(data); + } + + request = request.headers(headers); + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", request); + + let fut = async move { request.send().await.map_err(map_request_error) }; + + let mut resources_table = webview.resources_table(); + let rid = resources_table.add_request(Box::pin(fut)); + + Ok(rid) + } + "data" => { + let data_url = + data_url::DataUrl::process(url.as_str()).map_err(|_| Error::DataUrlError)?; + let (body, _) = data_url + .decode_to_vec() + .map_err(|_| Error::DataUrlDecodeError)?; + + let response = http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, data_url.mime_type().to_string()) + .body(reqwest::Body::from(body))?; + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", response); + + let fut = async move { Ok(reqwest::Response::from(response)) }; + let mut resources_table = webview.resources_table(); + let rid = resources_table.add_request(Box::pin(fut)); + Ok(rid) + } + _ => Err(Error::SchemeNotSupport(scheme.to_string())), + } +} + +#[command] +pub fn fetch_cancel(webview: Webview, rid: ResourceId) -> crate::Result<()> { + let mut resources_table = webview.resources_table(); + let req = resources_table.get::(rid)?; + let abort_tx = resources_table.take::(req.abort_tx_rid)?; + if let Some(abort_tx) = Arc::into_inner(abort_tx) { + abort_tx.abort(); + } + Ok(()) +} + +#[command] +pub async fn fetch_send( + webview: Webview, + rid: ResourceId, +) -> crate::Result { + let (req, abort_rx) = { + let mut resources_table = webview.resources_table(); + let req = resources_table.get::(rid)?; + let abort_rx = resources_table.take::(req.abort_rx_rid)?; + (req, abort_rx) + }; + + let Some(abort_rx) = Arc::into_inner(abort_rx) else { + return Err(Error::RequestCanceled); + }; + + let mut fut = req.fut.lock().await; + + let res = tokio::select! { + res = fut.as_mut() => res?, + _ = abort_rx.0 => { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; + return Err(Error::RequestCanceled); + } + }; + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", res); + + let status = res.status(); + let url = res.url().to_string(); + let mut headers = Vec::new(); + for (key, val) in res.headers().iter() { + headers.push(( + key.as_str().into(), + String::from_utf8(val.as_bytes().to_vec())?, + )); + } + + let mut resources_table = webview.resources_table(); + let rid = resources_table.add(ReqwestResponse(res)); + + Ok(FetchResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or_default().to_string(), + headers, + url, + rid, + }) +} + +#[command] +pub async fn fetch_read_body( + webview: Webview, + rid: ResourceId, +) -> crate::Result { + let res = { + let resources_table = webview.resources_table(); + resources_table.get::(rid)? + }; + + // SAFETY: we can access the inner value mutably + // because we are the only ones with a reference to it + // and we don't want to use `Arc::into_inner` because we want to keep the value in the table + // for potential future calls to `fetch_cancel_body` + let res_ptr = Arc::as_ptr(&res) as *mut ReqwestResponse; + let res = unsafe { &mut *res_ptr }; + let res = &mut res.0; + + let Some(chunk) = res.chunk().await? else { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; + + // return a response with a single byte to indicate that the body is empty + return Ok(tauri::ipc::Response::new(vec![1])); + }; + + let mut chunk = chunk.to_vec(); + // append a 0 byte to indicate that the body is not empty + chunk.push(0); + + Ok(tauri::ipc::Response::new(chunk)) +} + +#[command] +pub async fn fetch_cancel_body( + webview: Webview, + rid: ResourceId, +) -> crate::Result<()> { + let mut resources_table = webview.resources_table(); + resources_table.close(rid)?; + Ok(()) +} + +// forbidden headers per fetch spec https://fetch.spec.whatwg.org/#terminology-headers +#[cfg(not(feature = "unsafe-headers"))] +fn is_unsafe_header(header: &HeaderName) -> bool { + matches!( + *header, + header::ACCEPT_CHARSET + | header::ACCEPT_ENCODING + | header::ACCESS_CONTROL_REQUEST_HEADERS + | header::ACCESS_CONTROL_REQUEST_METHOD + | header::CONNECTION + | header::CONTENT_LENGTH + | header::COOKIE + | header::DATE + | header::DNT + | header::EXPECT + | header::HOST + | header::ORIGIN + | header::REFERER + | header::SET_COOKIE + | header::TE + | header::TRAILER + | header::TRANSFER_ENCODING + | header::UPGRADE + | header::VIA + ) || { + let lower = header.as_str().to_lowercase(); + lower.starts_with("proxy-") || lower.starts_with("sec-") + } +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufRead, BufReader, Write}, + net::TcpListener, + sync::Arc, + }; + + use super::*; + + /// Test server with an open redirect: + /// + /// - `/redirect-external` answers a 302 to `http://127.0.0.1:{port}/secret`, which is a + /// different origin as far as the scope is concerned - the tests allow `localhost`; + /// - `/redirect/{n}` answers a 302 to `/redirect/{n-1}` on `localhost`, and `/redirect/0` + /// answers a 302 to `/target`, so the whole chain stays in scope; + /// - anything else answers a 200, so a bypass shows up as a successful response. + fn spawn_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + + // the whole request must be consumed, otherwise closing the connection + // with unread data queued resets it before the client reads the response + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if matches!(header.as_str(), "" | "\r\n" | "\n") { + break; + } + } + + let path = request_line.split(' ').nth(1).unwrap_or("/"); + let location = match path.strip_prefix("/redirect/") { + Some("0") => Some(format!("http://localhost:{port}/target")), + Some(remaining) => Some(format!( + "http://localhost:{port}/redirect/{}", + remaining.parse::().unwrap() - 1 + )), + None if path == "/redirect-external" => { + Some(format!("http://127.0.0.1:{port}/secret")) + } + None => None, + }; + + let response = match location { + Some(location) => format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ), + None => "HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nsecret" + .to_string(), + }; + + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + port + } + + fn get( + url: &str, + scope: Option, + max_redirections: Option, + ) -> Result { + let client = reqwest::ClientBuilder::new() + .redirect(redirect_policy(scope, max_redirections)) + .build() + .unwrap(); + let request = client.get(url); + tauri::async_runtime::block_on( + async move { request.send().await.map_err(map_request_error) }, + ) + } + + fn localhost_scope(port: u16) -> Option { + let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); + Some(Scope::new(vec![entry], Vec::new())) + } + + #[test] + fn redirect_outside_of_scope_is_denied() { + let port = spawn_server(); + + let err = get( + &format!("http://localhost:{port}/redirect-external"), + localhost_scope(port), + None, + ) + .unwrap_err(); + + match err { + Error::UrlNotAllowed(url) => { + assert_eq!(url.as_str(), format!("http://127.0.0.1:{port}/secret")) + } + e => panic!("expected the redirect to be denied by the scope, got {e:?}"), + } + } + + #[test] + fn redirect_outside_of_scope_is_followed_when_not_configured() { + let port = spawn_server(); + + // `scopeRedirects` is disabled, so only the URL requested by the frontend is checked + let response = get( + &format!("http://localhost:{port}/redirect-external"), + None, + None, + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn redirect_denied_by_the_scope_is_denied() { + let port = spawn_server(); + let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); + let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap()); + let scope = Some(Scope::new(vec![allow], vec![deny])); + + let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err(); + + assert!(matches!(err, Error::UrlNotAllowed(_))); + } + + #[test] + fn redirect_inside_of_scope_is_followed() { + let port = spawn_server(); + + let response = get( + &format!("http://localhost:{port}/redirect/2"), + localhost_scope(port), + None, + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.url().path(), "/target"); + } + + #[test] + fn max_redirections_is_enforced() { + let port = spawn_server(); + + for scope in [localhost_scope(port), None] { + let err = get( + &format!("http://localhost:{port}/redirect/5"), + scope, + Some(2), + ) + .unwrap_err(); + + assert!(matches!(err, Error::Network(e) if e.is_redirect())); + } + } + + #[test] + fn zero_max_redirections_does_not_follow() { + let port = spawn_server(); + + let response = get( + &format!("http://localhost:{port}/redirect/0"), + localhost_scope(port), + Some(0), + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::FOUND); + } +} diff --git a/plugins/http/src/config.rs b/plugins/http/src/config.rs new file mode 100644 index 0000000..02f4e92 --- /dev/null +++ b/plugins/http/src/config.rs @@ -0,0 +1,47 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; + +/// HTTP plugin configuration, defined on the `plugins > http` object of your `tauri.conf.json`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + /// Whether the scope is checked on every hop of a redirect chain instead of only on the + /// URL requested by the frontend. Defaults to `false`. + /// + /// When disabled, a server on an allowed origin can answer with a redirect to any other + /// origin - an open redirect, or a server an attacker controls - and the plugin follows it, + /// handing the webview a response from a URL the scope denies, such as a `localhost` + /// service, an internal host or a cloud metadata endpoint. + /// + /// When enabled, a redirect to a URL that is not allowed by the scope fails with + /// [`Error::UrlNotAllowed`](crate::Error::UrlNotAllowed) instead of being followed, so every + /// redirect target must also be part of the scope. This is opt-in because it breaks + /// applications that rely on being redirected outside of their configured scope. + // TODO(v3): enforce the scope on redirects by default and remove this option + #[serde(default)] + pub scope_redirects: bool, +} + +#[cfg(test)] +mod tests { + use super::Config; + + #[test] + fn deserializes_the_plugin_configuration() { + // the plugin configuration is optional, and the scope check is opt-in + let config: Option = serde_json::from_value(serde_json::Value::Null).unwrap(); + assert!(config.is_none()); + + let config: Config = serde_json::from_str("{}").unwrap(); + assert!(!config.scope_redirects); + + let config: Config = serde_json::from_str(r#"{ "scopeRedirects": true }"#).unwrap(); + assert!(config.scope_redirects); + + // a typo must not silently disable the scope check + assert!(serde_json::from_str::(r#"{ "scopeRedirect": true }"#).is_err()); + } +} diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs new file mode 100644 index 0000000..2b974e6 --- /dev/null +++ b/plugins/http/src/error.rs @@ -0,0 +1,84 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, Serializer}; +use url::Url; + +/// Errors that can happen while using the HTTP plugin. +/// +/// The error is serialized to the frontend as its [`Display`](std::fmt::Display) string. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// JSON serialization or deserialization error. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// File system error, raised for instance when the cookie store file cannot be created or + /// opened on startup. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Error from the underlying [`reqwest`] client, raised while building the client, sending the + /// request or reading the response body. + #[error(transparent)] + Network(#[from] reqwest::Error), + /// Error from the [`http`] crate, raised while building the response of a `data:` URL. + #[error(transparent)] + Http(#[from] http::Error), + /// A header name given by the frontend is not a valid HTTP header name. + #[error(transparent)] + HttpInvalidHeaderName(#[from] http::header::InvalidHeaderName), + /// A header value is not a valid HTTP header value. + #[error(transparent)] + HttpInvalidHeaderValue(#[from] http::header::InvalidHeaderValue), + /// URL not allowed by the scope. + /// + /// Raised for the URL requested by the frontend, and - when the + /// [`scope_redirects`](crate::Config::scope_redirects) configuration is enabled - for any + /// redirect target that is not allowed by the scope. + #[error("url not allowed on the configured scope: {0}")] + UrlNotAllowed(Url), + /// Failed to parse a URL. + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + /// HTTP method error. + #[error(transparent)] + HttpMethod(#[from] http::method::InvalidMethod), + /// The requested URL uses an unsupported scheme. Only `http`, `https` and `data` are handled. + #[error("scheme {0} not supported")] + SchemeNotSupport(String), + /// The request was aborted by the frontend before the response was received. + #[error("Request canceled")] + RequestCanceled, + /// Error from the file system plugin. + #[error(transparent)] + FsError(#[from] tauri_plugin_fs::Error), + /// The `data:` URL could not be processed. + #[error("failed to process data url")] + DataUrlError, + /// The body of the `data:` URL could not be decoded into bytes. + #[error("failed to decode data url into bytes")] + DataUrlDecodeError, + /// Error from the Tauri APIs, raised for instance while resolving a path or while reading the + /// webview resource table. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// A response header value is not valid UTF-8, so it cannot be forwarded to the frontend. + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + /// The frontend requested dangerous client settings, but the `dangerous-settings` Cargo + /// feature is not enabled. + #[error("dangerous settings used but are not enabled")] + DangerousSettings, +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. +pub type Result = std::result::Result; diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs new file mode 100644 index 0000000..6444ef9 --- /dev/null +++ b/plugins/http/src/lib.rs @@ -0,0 +1,160 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Access the HTTP client written in Rust. +//! +//! ## Cargo features +//! +//! ### Reqwest feature forwards +//! +//! These features forwards [`reqwest`](https://docs.rs/reqwest/0.12.28/reqwest/index.html) features: +//! +//! - **http2** *(enabled by default)*: Enables HTTP/2 support. +//! - **native-tls**: Enables TLS functionality provided by native-tls. +//! - **native-tls-vendored**: Enables the vendored feature of native-tls. +//! - **native-tls-alpn**: Enables the alpn feature of native-tls. +//! - **rustls-tls** *(enabled by default)*: Enables TLS functionality provided by rustls. Equivalent to +//! rustls-tls-webpki-roots. +//! - **rustls-tls-manual-roots**: Enables TLS functionality provided by rustls, without setting any root +//! certificates. Roots have to be specified manually. +//! - **rustls-tls-webpki-roots**: Enables TLS functionality provided by rustls, while using root certificates +//! from the webpki-roots crate. +//! - **rustls-tls-native-roots**: Enables TLS functionality provided by rustls, while using root certificates +//! from the rustls-native-certs crate. +//! - **blocking**: Provides the [blocking](https://docs.rs/reqwest/0.12.28/reqwest/blocking/index.html) client API. +//! - **charset** *(enabled by default)*: Improved support for decoding text. +//! - **cookies** *(enabled by default)*: Provides cookie session support. +//! - **gzip**: Provides response body gzip decompression. +//! - **brotli**: Provides response body brotli decompression. +//! - **zstd**: Provides response body zstd decompression. +//! - **deflate**: Provides response body deflate decompression. +//! - **json**: Provides serialization and deserialization for JSON bodies. +//! - **multipart**: Provides functionality for multipart forms. +//! - **stream**: Adds support for futures::Stream. +//! - **socks**: Provides SOCKS5 proxy support. +//! - **trust-dns**: Enables a trust-dns/Hickory DNS async resolver instead of the default threadpool using +//! getaddrinfo. +//! - **macos-system-configuration** *(deprecated, use `system-proxy` instead)*: Use Windows and macOS system proxy settings automatically. +//! - **system-proxy** *(enabled by default)*: Use Windows and macOS system proxy settings automatically. +//! +//! ### tauri-plugin-http features +//! +//! - **tracing**: Adds request, response, and cookie-store diagnostics through `tracing`. +//! - **unsafe-headers**: Allows webview requests to send any headers. +//! - **dangerous-settings**: Allows dangerous client settings such as accepting invalid certificates or hostnames. +//! +//! ## Configuration +//! +//! See [`Config`] for the options that can be set on the `plugins > http` object of your +//! `tauri.conf.json`: +//! +//! ```json +//! { +//! "plugins": { +//! "http": { +//! "scopeRedirects": true +//! } +//! } +//! } +//! ``` + +pub use reqwest; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use config::Config; +pub use error::{Error, Result}; + +mod commands; +mod config; +mod error; +#[cfg(feature = "cookies")] +mod reqwest_cookie_store; +mod scope; + +#[cfg(feature = "cookies")] +const COOKIES_FILENAME: &str = ".cookies"; + +pub(crate) struct Http { + pub(crate) config: Config, + #[cfg(feature = "cookies")] + cookies_jar: std::sync::Arc, +} + +/// Initializes the plugin. +/// +/// The plugin reads its [`Config`] from the `plugins > http` object of the `tauri.conf.json` file; +/// when that object is missing, [`Config::default`] is used. +/// +/// With the `cookies` Cargo feature (enabled by default), a cookie jar is loaded from a `.cookies` +/// file in the application cache directory on setup and written back to it when the application +/// exits. A jar that cannot be read is replaced by an empty one. +/// +/// Register it on the Tauri builder with `.plugin(tauri_plugin_http::init())`. +pub fn init() -> TauriPlugin> { + Builder::>::new("http") + .setup(|app, api| { + #[cfg(feature = "cookies")] + let cookies_jar = { + use crate::reqwest_cookie_store::*; + use std::fs::File; + use std::io::BufReader; + + let cache_dir = app.path().app_cache_dir()?; + std::fs::create_dir_all(&cache_dir)?; + + let path = cache_dir.join(COOKIES_FILENAME); + let file = File::options() + .create(true) + .append(true) + .read(true) + .open(&path)?; + + let reader = BufReader::new(file); + CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| { + #[cfg(feature = "tracing")] + tracing::warn!( + "failed to load cookie store: {_e}, falling back to empty store" + ); + CookieStoreMutex::new(path, Default::default()) + }) + }; + + let state = Http { + config: api.config().clone().unwrap_or_default(), + #[cfg(feature = "cookies")] + cookies_jar: std::sync::Arc::new(cookies_jar), + }; + + app.manage(state); + + Ok(()) + }) + .on_event(|app, event| { + #[cfg(feature = "cookies")] + if let tauri::RunEvent::Exit = event { + let state = app.state::(); + + match state.cookies_jar.request_save() { + Ok(rx) => { + let _ = rx.recv(); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + } + }) + .invoke_handler(tauri::generate_handler![ + commands::fetch, + commands::fetch_cancel, + commands::fetch_send, + commands::fetch_read_body, + commands::fetch_cancel_body, + ]) + .build() +} diff --git a/plugins/http/src/reqwest_cookie_store.rs b/plugins/http/src/reqwest_cookie_store.rs new file mode 100644 index 0000000..0b71902 --- /dev/null +++ b/plugins/http/src/reqwest_cookie_store.rs @@ -0,0 +1,133 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// taken from https://github.com/pfernie/reqwest_cookie_store/blob/2ec4afabcd55e24d3afe3f0626ee6dc97bed938d/src/lib.rs + +use std::{ + path::PathBuf, + sync::{mpsc::Receiver, Mutex}, +}; + +use cookie_store::{CookieStore, RawCookie, RawCookieParseError}; +use reqwest::header::HeaderValue; + +fn set_cookies( + cookie_store: &mut CookieStore, + cookie_headers: &mut dyn Iterator, + url: &url::Url, +) { + let cookies = cookie_headers.filter_map(|val| { + std::str::from_utf8(val.as_bytes()) + .map_err(RawCookieParseError::from) + .and_then(RawCookie::parse) + .map(|c| c.into_owned()) + .ok() + }); + cookie_store.store_response_cookies(cookies, url); +} + +fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option { + let s = cookie_store + .get_request_values(url) + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; "); + + if s.is_empty() { + return None; + } + + HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok() +} + +/// A [`cookie_store::CookieStore`] wrapped internally by a [`std::sync::Mutex`], suitable for use in +/// async/concurrent contexts. +#[derive(Debug)] +pub struct CookieStoreMutex { + pub path: PathBuf, + store: Mutex, + save_task: Mutex>, +} + +impl CookieStoreMutex { + /// Create a new [`CookieStoreMutex`] from an existing [`cookie_store::CookieStore`]. + pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex { + CookieStoreMutex { + path, + store: Mutex::new(cookie_store), + save_task: Default::default(), + } + } + + pub fn load( + path: PathBuf, + reader: R, + ) -> cookie_store::Result { + cookie_store::serde::load(reader, |c| serde_json::from_str(c)) + .map(|store| CookieStoreMutex::new(path, store)) + } + + fn cookies_to_str(&self) -> Result { + let mut cookies = Vec::new(); + for cookie in self + .store + .lock() + .expect("poisoned cookie jar mutex") + .iter_unexpired() + { + if cookie.is_persistent() { + cookies.push(cookie.clone()); + } + } + serde_json::to_string(&cookies) + } + + pub fn request_save(&self) -> cookie_store::Result> { + let cookie_str = self.cookies_to_str()?; + let path = self.path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let task = tauri::async_runtime::spawn(async move { + match tokio::fs::write(&path, &cookie_str).await { + Ok(()) => { + let _ = tx.send(()); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + }); + self.save_task + .lock() + .unwrap() + .replace(CancellableTask(task)); + Ok(rx) + } +} + +impl reqwest::cookie::CookieStore for CookieStoreMutex { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { + set_cookies(&mut self.store.lock().unwrap(), cookie_headers, url); + + // try to persist cookies immediately asynchronously + if let Err(_e) = self.request_save() { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + + fn cookies(&self, url: &url::Url) -> Option { + let store = self.store.lock().unwrap(); + cookies(&store, url) + } +} + +#[derive(Debug)] +struct CancellableTask(tauri::async_runtime::JoinHandle<()>); + +impl Drop for CancellableTask { + fn drop(&mut self) { + self.0.abort(); + } +} diff --git a/plugins/http/src/scope.rs b/plugins/http/src/scope.rs new file mode 100644 index 0000000..a7745c2 --- /dev/null +++ b/plugins/http/src/scope.rs @@ -0,0 +1,230 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::Arc; + +use serde::{Deserialize, Deserializer}; +use url::Url; +use urlpattern::{UrlPattern, UrlPatternMatchInput}; + +#[allow(rustdoc::bare_urls)] +#[derive(Debug)] +pub struct Entry { + pub url: UrlPattern, +} + +fn parse_url_pattern(s: &str) -> Result { + let mut init = urlpattern::UrlPatternInit::parse_constructor_string::(s, None)?; + if init.search.as_ref().map(|p| p.is_empty()).unwrap_or(true) { + init.search.replace("*".to_string()); + } + if init.hash.as_ref().map(|p| p.is_empty()).unwrap_or(true) { + init.hash.replace("*".to_string()); + } + if init + .pathname + .as_ref() + .map(|p| p.is_empty() || p == "/") + .unwrap_or(true) + { + init.pathname.replace("*".to_string()); + } + UrlPattern::parse(init, Default::default()) +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum EntryRaw { + Value(String), + Object { url: String }, +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + EntryRaw::deserialize(deserializer).and_then(|raw| { + let url = match raw { + EntryRaw::Value(url) => url, + EntryRaw::Object { url } => url, + }; + Ok(Entry { + url: parse_url_pattern(&url).map_err(|e| { + serde::de::Error::custom(format!("`{url}` is not a valid URL pattern: {e}")) + })?, + }) + }) + } +} + +/// Scope for HTTP access. +#[derive(Debug)] +pub struct Scope { + allowed: Vec>, + denied: Vec>, +} + +impl Scope { + /// Creates a new scope from the scope configuration. + pub(crate) fn new(allowed: Vec>, denied: Vec>) -> Self { + Self { allowed, denied } + } + + /// Determines if the given URL is allowed on this scope. + pub fn is_allowed(&self, url: &Url) -> bool { + let denied = self.denied.iter().any(|entry| { + entry + .url + .test(UrlPatternMatchInput::Url(url.clone())) + .unwrap_or_default() + }); + if denied { + false + } else { + self.allowed.iter().any(|entry| { + entry + .url + .test(UrlPatternMatchInput::Url(url.clone())) + .unwrap_or_default() + }) + } + } +} + +#[cfg(test)] +impl std::str::FromStr for Entry { + type Err = urlpattern::quirks::Error; + + fn from_str(s: &str) -> Result { + let url = parse_url_pattern(s)?; + Ok(Self { url }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + #[test] + fn denied_takes_precedence() { + let allow = Arc::new("http://localhost:8080/file.png".parse().unwrap()); + let deny = Arc::new("http://localhost:8080/*".parse().unwrap()); + let scope = super::Scope::new(vec![allow], vec![deny]); + assert!(!scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080?framework=tauri".parse().unwrap())); + } + + #[test] + fn fixed_url() { + // plain URL + let entry = Arc::new("http://localhost:8080".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + assert!(scope.is_allowed(&"http://localhost:8080".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/path/to/asset.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/path/list?limit=50".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://localhost:8080".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8081".parse().unwrap())); + assert!(!scope.is_allowed(&"http://local:8080".parse().unwrap())); + } + + #[test] + fn fixed_path() { + // URL with fixed path + let entry = Arc::new("http://localhost:8080/file.png".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file.png?q=1".parse().unwrap())); + + assert!(!scope.is_allowed(&"http://localhost:8080".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080/file".parse().unwrap())); + assert!(!scope.is_allowed(&"http://localhost:8080/file.png/other.jpg".parse().unwrap())); + } + + #[test] + fn pattern_wildcard() { + let entry = Arc::new("http://localhost:8080/*.png".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://localhost:8080/file.png".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/file.png#head".parse().unwrap())); + assert!(scope.is_allowed(&"http://localhost:8080/assets/file.png".parse().unwrap())); + assert!(scope.is_allowed( + &"http://localhost:8080/assets/file.png?width=100&height=200" + .parse() + .unwrap() + )); + + assert!(!scope.is_allowed(&"http://localhost:8080/file.jpeg".parse().unwrap())); + } + + #[test] + fn domain_wildcard() { + let entry = Arc::new("http://*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else#tauri".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else?rel=tauri".parse().unwrap())); + assert!(scope.is_allowed( + &"http://something.else/path/to/file.mp4?start=500" + .parse() + .unwrap() + )); + + assert!(!scope.is_allowed(&"https://something.else".parse().unwrap())); + + let entry = Arc::new("http://*/*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + } + + #[test] + fn scheme_wildcard() { + let entry = Arc::new("*://*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"file://path".parse().unwrap())); + assert!(scope.is_allowed(&"file://path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else?x=1#frag".parse().unwrap())); + + let entry = Arc::new("*://*/*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"file://path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"https://something.else".parse().unwrap())); + } + + #[test] + fn validate_query() { + let entry = Arc::new("https://tauri.app/path?x=*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"https://tauri.app/path?x=5".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://tauri.app/path?y=5".parse().unwrap())); + } + + #[test] + fn validate_hash() { + let entry = Arc::new("https://tauri.app/path#frame*".parse().unwrap()); + let scope = super::Scope::new(vec![entry], Vec::new()); + + assert!(scope.is_allowed(&"https://tauri.app/path#frame".parse().unwrap())); + + assert!(!scope.is_allowed(&"https://tauri.app/path#work".parse().unwrap())); + } +} diff --git a/plugins/http/tsconfig.json b/plugins/http/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/http/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/localhost/CHANGELOG.md b/plugins/localhost/CHANGELOG.md new file mode 100644 index 0000000..c9e7c64 --- /dev/null +++ b/plugins/localhost/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +## \[2.3.2] + +- [`8bfa4450`](https://github.com/tauri-apps/plugins-workspace/commit/8bfa4450230d6a00f836bd27944c34cd7fe43e08) ([#3112](https://github.com/tauri-apps/plugins-workspace/pull/3112) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Disable caching on responses. + +## \[2.3.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.1] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.1.0] + +- [`3449dd5a`](https://github.com/tauri-apps/plugins-workspace/commit/3449dd5a8f6d12fee8d6389c034fe47e19d72bcd) ([#1982](https://github.com/tauri-apps/plugins-workspace/pull/1982) by [@arihav](https://github.com/tauri-apps/plugins-workspace/../../arihav)) Add custom host binding to allow external access + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.9] + +- [`e847cedc`](https://github.com/tauri-apps/plugins-workspace/commit/e847cedc1f46f3e7a2ad81ea579b620bc5b992d7) ([#1402](https://github.com/tauri-apps/plugins-workspace/pull/1402) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Use no default features on tauri for all plugins so that consumers can use `default-features = false` on tauri, note that this will still enable wry feature on iOS +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.8] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.7] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.6] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.5] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.4] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`14f381a`](https://github.com/tauri-apps/plugins-workspace/commit/14f381acf8fe690acecc676922c6f05939b95734) Update MSRV to 1.75. +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Add permissions. + +## \[2.0.0-alpha.6] + +- [`2cf8faa`](https://github.com/tauri-apps/plugins-workspace/commit/2cf8faa3e149af55eb86e5aba8ebfc54210ca703)([#839](https://github.com/tauri-apps/plugins-workspace/pull/839)) Update to tauri@alpha.20. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to tauri@alpha.18. + +## \[2.0.0-alpha.4] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to tauri@alpha.17. + +## \[2.0.0-alpha.3] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update MSRV to 1.75. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d5a7c77`](https://github.com/tauri-apps/plugins-workspace/commit/d5a7c77a8d0e7912a6b07b22ed329004edd6e80b)([#545](https://github.com/tauri-apps/plugins-workspace/pull/545)) Fixes docs.rs build by enabling the `tauri/dox` feature flag. +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/localhost/Cargo.toml b/plugins/localhost/Cargo.toml new file mode 100644 index 0000000..c37476a --- /dev/null +++ b/plugins/localhost/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tauri-plugin-localhost" +version = "2.3.2" +description = "Expose your apps assets through a localhost server instead of the default custom protocol." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +exclude = ["/banner.png"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +tiny_http = "0.12" +http = "1" diff --git a/plugins/localhost/LICENSE.spdx b/plugins/localhost/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/localhost/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/localhost/LICENSE_APACHE-2.0 b/plugins/localhost/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/localhost/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/localhost/LICENSE_MIT b/plugins/localhost/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/localhost/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/localhost/README.md b/plugins/localhost/README.md new file mode 100644 index 0000000..8ab13b2 --- /dev/null +++ b/plugins/localhost/README.md @@ -0,0 +1,108 @@ +![plugin-localhost](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/localhost/banner.png) + +Expose your apps assets through a localhost server instead of the default custom protocol. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +> Note: This plugins brings considerable security risks and you should only use it if you know what your are doing. If in doubt, use the default custom protocol implementation. + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +portpicker = "0.1" # used in the example to pick a random free port +tauri-plugin-localhost = "2.0.0" +# alternatively with Git: +tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +#[cfg(not(dev))] +use tauri::{ipc::CapabilityBuilder, Manager, Url}; +use tauri::{WebviewUrl, WebviewWindowBuilder}; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let port = portpicker::pick_unused_port().expect("failed to find unused port"); + + tauri::Builder::default() + .plugin(tauri_plugin_localhost::Builder::new(port).build()) + .setup(move |app| { + // In `tauri dev` mode you usually use your dev server. + #[cfg(dev)] + let url = WebviewUrl::App(std::path::PathBuf::from("/")); + + #[cfg(not(dev))] + let url = { + let url: Url = format!("http://localhost:{}", port).parse().unwrap(); + + app.add_capability( + CapabilityBuilder::new("localhost") + .remote(url.to_string()) + .window("main"), + )?; + + WebviewUrl::External(url) + }; + + // This requires you to remove the window from tauri.conf.json + WebviewWindowBuilder::new(app, "main".to_string(), url) + .title("Localhost Example") + .build()?; + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/localhost/SECURITY.md b/plugins/localhost/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/localhost/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/localhost/banner.png b/plugins/localhost/banner.png new file mode 100644 index 0000000..3fe615c Binary files /dev/null and b/plugins/localhost/banner.png differ diff --git a/plugins/localhost/src/lib.rs b/plugins/localhost/src/lib.rs new file mode 100644 index 0000000..aba034b --- /dev/null +++ b/plugins/localhost/src/lib.rs @@ -0,0 +1,174 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Expose your apps assets through a localhost server instead of the default custom protocol. +//! +//! **Note: This plugins brings considerable security risks and you should only use it if you know what your are doing. If in doubt, use the default custom protocol implementation.** + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use std::collections::HashMap; + +use http::Uri; +use tauri::{ + plugin::{Builder as PluginBuilder, TauriPlugin}, + Runtime, +}; +use tiny_http::{Header, Response as HttpResponse, Server}; + +/// An incoming HTTP request received by the localhost server, passed to the +/// [`Builder::on_request`] hook. +pub struct Request { + url: String, +} + +impl Request { + /// The request URL (path and, if present, query string), exactly as sent by the client. + pub fn url(&self) -> &str { + &self.url + } +} + +/// The HTTP response the localhost server is about to send back for a matched asset. Passed +/// mutably to the [`Builder::on_request`] hook so it can add or override headers before the +/// response is written to the client. +pub struct Response { + headers: HashMap, +} + +impl Response { + /// Adds a header to the response, replacing any existing header with the same name. + pub fn add_header, V: Into>(&mut self, header: H, value: V) { + self.headers.insert(header.into(), value.into()); + } +} + +type OnRequest = Option>; + +/// Builds the localhost plugin. +/// +/// **Note: This plugin brings considerable security risks and you should only use it if you know +/// what you are doing. Because the server has no authentication, any local process can connect to +/// it and read the assets it serves. If in doubt, use the default custom protocol implementation.** +pub struct Builder { + port: u16, + host: Option, + on_request: OnRequest, +} + +impl Builder { + /// Creates a new [`Builder`] that will serve the app's assets on the given `port`, bound to + /// `localhost` unless [`Self::host`] is called. + /// + /// # Examples + /// + /// ```no_run + /// fn setup(builder: tauri::Builder) -> tauri::Builder { + /// builder.plugin( + /// tauri_plugin_localhost::Builder::new(9527) + /// .host("127.0.0.1") + /// .on_request(|request, response| { + /// println!("{}", request.url()); + /// }) + /// .build(), + /// ) + /// } + /// ``` + pub fn new(port: u16) -> Self { + Self { + port, + host: None, + on_request: None, + } + } + + /// Sets the host the localhost server binds to. Defaults to `localhost`. + pub fn host>(mut self, host: H) -> Self { + self.host = Some(host.into()); + self + } + + /// Sets a hook that is called for every request that resolves to a known frontend asset, + /// right before the response is sent. Use it to inspect the [`Request`] and add or override + /// headers on the [`Response`], for example to append custom CORS or caching headers. + pub fn on_request( + mut self, + f: F, + ) -> Self { + self.on_request.replace(Box::new(f)); + self + } + + /// Builds the plugin, ready to be registered with [`tauri::Builder::plugin`]. + /// + /// On setup it spawns a background thread that starts a `tiny_http` server bound to + /// `host:port`. For every incoming request whose path resolves to a known frontend asset, the + /// server responds with that asset's bytes, automatically setting the `Content-Type`, the + /// `Content-Security-Policy` (when the asset has one) and a `Cache-Control: no-cache` header, + /// then invoking the [`Self::on_request`] hook (if any) before writing the response. + /// + /// # Panics + /// + /// Panics on the background thread if the server fails to bind to `host:port`, or if it fails + /// to send a response for a request. + pub fn build(mut self) -> TauriPlugin { + let port = self.port; + let host = self.host.unwrap_or("localhost".to_string()); + let on_request = self.on_request.take(); + + PluginBuilder::new("localhost") + .setup(move |app, _api| { + let asset_resolver = app.asset_resolver(); + std::thread::spawn(move || { + let server = + Server::http(format!("{host}:{port}")).expect("Unable to spawn server"); + for req in server.incoming_requests() { + let path = req + .url() + .parse::() + .map(|uri| uri.path().into()) + .unwrap_or_else(|_| req.url().into()); + + #[allow(unused_mut)] + if let Some(mut asset) = asset_resolver.get(path) { + let request = Request { + url: req.url().into(), + }; + let mut response = Response { + headers: Default::default(), + }; + + response.add_header("Content-Type", asset.mime_type); + if let Some(csp) = asset.csp_header { + response + .headers + .insert("Content-Security-Policy".into(), csp); + } + + response + .headers + .insert("Cache-Control".into(), "no-cache".into()); + + if let Some(on_request) = &on_request { + on_request(&request, &mut response); + } + + let mut resp = HttpResponse::from_data(asset.bytes); + for (header, value) in response.headers { + if let Ok(h) = Header::from_bytes(header.as_bytes(), value) { + resp.add_header(h); + } + } + req.respond(resp).expect("unable to setup response"); + } + } + }); + Ok(()) + }) + .build() + } +} diff --git a/plugins/log/CHANGELOG.md b/plugins/log/CHANGELOG.md new file mode 100644 index 0000000..11aea9e --- /dev/null +++ b/plugins/log/CHANGELOG.md @@ -0,0 +1,188 @@ +# Changelog + +## [2.9.2] + +- [`2df3d936`](https://github.com/tauri-apps/plugins-workspace/commit/2df3d9368187e1d2ce736d3e0b2d4bdcae35b011) ([#3574](https://github.com/tauri-apps/plugins-workspace/pull/3574)) Log webview log target locations after 2 colons instead of 1. (e.g. `webview:myFunction@http://localhost:5173/src/myFile.ts?t=1787480723620:47:4` to `webview::myFunction@http://localhost:5173/src/myFile.ts?t=1787480723620:47:4`) + +## [2.9.1] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +## \[2.9.0] + +- [`f08980f1`](https://github.com/tauri-apps/plugins-workspace/commit/f08980f123f191b9505bd290acd8fff0fdefeed9) ([#3445](https://github.com/tauri-apps/plugins-workspace/pull/3445) by [@bajoca05](https://github.com/tauri-apps/plugins-workspace/../../bajoca05)) Added the `FileOpenStrategy` for log rotation. It defaults to append into existing file if any (previous behaviour), and brings a new feature to create a new file per session: `FileOpenStrategy::Rotate`. +- [`254f222e`](https://github.com/tauri-apps/plugins-workspace/commit/254f222e0e2bc79370f977855b6b39d956d3b568) ([#3477](https://github.com/tauri-apps/plugins-workspace/pull/3477) by [@bajoca05](https://github.com/tauri-apps/plugins-workspace/../../bajoca05)) Migrated from the `log` crate's `kv_unstable` feature flag to the `kv` feature flag stabilized in `0.4.21`. +- [`0c23b8ec`](https://github.com/tauri-apps/plugins-workspace/commit/0c23b8ecfe7c2aca582a81ab7339b11e350b3cac) ([#3446](https://github.com/tauri-apps/plugins-workspace/pull/3446) by [@fee1-dead](https://github.com/tauri-apps/plugins-workspace/../../fee1-dead)) Removed an unused dependency `byte-unit`. + +## \[2.8.0] + +- [`2a625adf`](https://github.com/tauri-apps/plugins-workspace/commit/2a625adff30238904035b86b6e2db7595597e857) ([#3065](https://github.com/tauri-apps/plugins-workspace/pull/3065) by [@BinaryMuse](https://github.com/tauri-apps/plugins-workspace/../../BinaryMuse)) Allow specifying a log formatter per target using the `format` method on `Target`. +- [`ae278ddf`](https://github.com/tauri-apps/plugins-workspace/commit/ae278ddf60203da183fe2266c06a5bdeb909285c) ([#3110](https://github.com/tauri-apps/plugins-workspace/pull/3110) by [@liuzhch1](https://github.com/tauri-apps/plugins-workspace/../../liuzhch1)) Fix log file rotation when exceeding `max_file_size`. +- [`6de61f85`](https://github.com/tauri-apps/plugins-workspace/commit/6de61f854bee0c1d39e4ce5890b12754b931c163) ([#3113](https://github.com/tauri-apps/plugins-workspace/pull/3113) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Remove log delays for iOS simulators that are no longer necessary. + +## \[2.7.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.7.0] + +- [`625bb1c0`](https://github.com/tauri-apps/plugins-workspace/commit/625bb1c0965394b88522643731f78ccbcca84add) ([#2965](https://github.com/tauri-apps/plugins-workspace/pull/2965) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Re-export the log crate. + +## \[2.6.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.5.1] + +### bug + +- [`9799f0db`](https://github.com/tauri-apps/plugins-workspace/commit/9799f0dbabea0b572a9b9111954fbf9aca63da71) ([#2802](https://github.com/tauri-apps/plugins-workspace/pull/2802) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fixes iOS simulator still freezing sometimes due to early logging. + +## \[2.5.0] + +- [`106e46ed`](https://github.com/tauri-apps/plugins-workspace/commit/106e46ed5125be33d0427cab9c5c066802f68791) ([#677](https://github.com/tauri-apps/plugins-workspace/pull/677)) Added the `KeepSome` rotation strategy. Like `KeepAll` it will rename files when the max file size is exceeded but will keep only the specified amount of files around. + +## \[2.4.0] + +- [`c9b21f6f`](https://github.com/tauri-apps/plugins-workspace/commit/c9b21f6f4345806eff5f495885f20dea0082b7d7) ([#2625](https://github.com/tauri-apps/plugins-workspace/pull/2625) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Export the `LogLevel` type. +- [`9629c2f4`](https://github.com/tauri-apps/plugins-workspace/commit/9629c2f4f90a56b5c2d265d1d13d3af40fc0c525) ([#2600](https://github.com/tauri-apps/plugins-workspace/pull/2600) by [@exoego](https://github.com/tauri-apps/plugins-workspace/../../exoego)) Adds a new varient `TargetKind::Dispatch` that allows you to construct arbitrary log targets +- [`686a839c`](https://github.com/tauri-apps/plugins-workspace/commit/686a839c96fae1b0334f2df9dc76ca5cdbe00dbe) ([#2626](https://github.com/tauri-apps/plugins-workspace/pull/2626) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fix iOS app stuck when using the iOS Simulator and the log plugin due to a deadlock when calling os_log too early. + +### feat + +- [`60fc35d3`](https://github.com/tauri-apps/plugins-workspace/commit/60fc35d35cccaf1654eceb4446ecf0f89dc15502) ([#2576](https://github.com/tauri-apps/plugins-workspace/pull/2576) by [@3lpsy](https://github.com/tauri-apps/plugins-workspace/../../3lpsy)) Add a `tracing` feature to the `log` plugin that emits log messages to the `tracing` system. + +## \[2.3.1] + +- [`1bb1ced5`](https://github.com/tauri-apps/plugins-workspace/commit/1bb1ced53820127204aa7adf57510c1cbce55e12) ([#2524](https://github.com/tauri-apps/plugins-workspace/pull/2524) by [@elwerene](https://github.com/tauri-apps/plugins-workspace/../../elwerene)) enable TargetKind::LogDir on mobile + +## \[2.3.0] + +### feat + +- [`02481501`](https://github.com/tauri-apps/plugins-workspace/commit/024815018fbc63a37afc716796a454925aa7d25e) ([#2377](https://github.com/tauri-apps/plugins-workspace/pull/2377) by [@3lpsy](https://github.com/tauri-apps/plugins-workspace/../../3lpsy)) Add a `is_skip_logger` flag to the Log Plugin `Builder` struct, a `skip_logger()` method to the Builder, and logic to avoid acquiring (creating) a logger and attaching it to the global logger. Since acquire_logger is pub, a `LoggerNotInitialized` is added and returned if it's called when the `is_skip_looger` flag is set. Overall, this feature permits a user to avoid calling `attach_logger` which can only be called once in a program's lifetime and allows the user to control the logger returned from `logger()`. Additionally, it also will allow users to generate multiple Tauri Mock apps in test suites that run and parallel and have the `log` plugin attached (assuming they use `skip_logger()`). + +## \[2.2.3] + +- [`1a984659`](https://github.com/tauri-apps/plugins-workspace/commit/1a9846599b6a71faf330845847a30f6bf9735898) ([#2469](https://github.com/tauri-apps/plugins-workspace/pull/2469) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Update `objc2` crate to 0.6. No user facing changes. + +## \[2.2.2] + +- [`6b4c3917`](https://github.com/tauri-apps/plugins-workspace/commit/6b4c3917389f4bc489d03b48a837557ac0584175) ([#2401](https://github.com/tauri-apps/plugins-workspace/pull/2401) by [@Seishiin](https://github.com/tauri-apps/plugins-workspace/../../Seishiin)) Fix timezone_strategy overwriting previously set LogLevels. + +## \[2.2.1] + +- [`784a54a3`](https://github.com/tauri-apps/plugins-workspace/commit/784a54a39094dfbaaa8dd123eb083c04dc6c3bb2) ([#2344](https://github.com/tauri-apps/plugins-workspace/pull/2344) by [@madsmtm](https://github.com/tauri-apps/plugins-workspace/../../madsmtm)) Use `objc2` instead of `objc`. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.2] + +- [`69d508ee`](https://github.com/tauri-apps/plugins-workspace/commit/69d508ee6910ae4064f2398fbacb803b3944d6a8) ([#2157](https://github.com/tauri-apps/plugins-workspace/pull/2157) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Make log functions omit caller location when failed to parse it instead of throwing + +## \[2.0.1] + +- [`371a2f73`](https://github.com/tauri-apps/plugins-workspace/commit/371a2f7361e0b91cf66f1287ffb18b34414a6cb8) ([#2021](https://github.com/tauri-apps/plugins-workspace/pull/2021) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Make webview log target more consistent that it always starts with `webview` + +## \[2.0.2] + +- [`606fa08d`](https://github.com/tauri-apps/plugins-workspace/commit/606fa08dae1acd074b961fb360623f4c86f13ee8) ([#1997](https://github.com/tauri-apps/plugins-workspace/pull/1997) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) **Potentially breaking:** Updated `fern` from 0.6 to 0.7. This is technically a breaking change because `fern` is re-exported in `tauri-plugin-log`. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.9] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.9] + +- [`20a1d24e`](https://github.com/tauri-apps/plugins-workspace/commit/20a1d24ee004e77c2d12a0e20d258ce120216ed1) ([#1579](https://github.com/tauri-apps/plugins-workspace/pull/1579) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Added `Builder::split` which returns the raw logger implementation so you can pipe to other loggers such as `multi_log` or `tauri-plugin-devtools`. + +## \[2.0.0-beta.8] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.7] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.6] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.5] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.4] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`ed46dca`](https://github.com/tauri-apps/plugins-workspace/commit/ed46dca74ff3947dbbcb26a7b571c129bf925698) Added `attachLogger` helper function to register a function that should be called for each log entry. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/log/Cargo.toml b/plugins/log/Cargo.toml new file mode 100644 index 0000000..1bf62bb --- /dev/null +++ b/plugins/log/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "tauri-plugin-log" +version = "2.9.2" +description = "Configurable logging for your Tauri app." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-log" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +thiserror = { workspace = true } +serde_repr = "0.1" +log = { workspace = true, features = ["kv"] } +time = { version = "0.3", features = [ + "formatting", + "local-offset", + "macros", + "parsing", +] } +fern = "0.7" +tracing = { workspace = true, optional = true } + +[target."cfg(target_os = \"android\")".dependencies] +android_logger = "0.15" + +[target."cfg(target_os = \"ios\")".dependencies] +swift-rs = "1" +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSString", +] } + +[features] +colored = ["fern/colored"] +tracing = ["dep:tracing"] diff --git a/plugins/log/LICENSE.spdx b/plugins/log/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/log/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/log/LICENSE_APACHE-2.0 b/plugins/log/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/log/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/log/LICENSE_MIT b/plugins/log/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/log/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/log/README.md b/plugins/log/README.md new file mode 100644 index 0000000..81ca9fd --- /dev/null +++ b/plugins/log/README.md @@ -0,0 +1,132 @@ +![plugin-log](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/log/banner.png) + +Configurable logging for your Tauri app. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-log = "2.0.0" +# alternatively with Git: +tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +If you want the single instance mechanism to only trigger for semver compatible instances of your apps, for example if you expect users to have multiple installations of your app installed, you can add `features = ["semver"]` to the dependency declaration in `Cargo.toml`. + +Then you can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-log +# or +npm add @tauri-apps/plugin-log +# or +yarn add @tauri-apps/plugin-log +``` + +## Usage + +First, you should enable the `log:default` capability: + +```json +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "log:default" # add this! + ] +} +``` + +Then, you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +use tauri_plugin_log::{Target, TargetKind}; + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_log::Builder::new().targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { file_name: None }), + Target::new(TargetKind::Webview), + ]).build()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { trace, info, error, attachConsole } from '@tauri-apps/plugin-log' + +// with TargetKind::Webview enabled this function will print logs to the browser console +const detach = await attachConsole() + +trace('Trace') +info('Info') +error('Error') + +// detach the browser console from the log stream +detach() +``` + +To log from rust code, add the log crate to your `Cargo.toml`: + +```toml +[dependencies] +log = "^0.4" +``` + +Now, you can use the macros provided by the log crate to log messages from your backend. See the [docs](https://docs.rs/log/latest) for more details. + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/log/SECURITY.md b/plugins/log/SECURITY.md new file mode 100644 index 0000000..d013f6a --- /dev/null +++ b/plugins/log/SECURITY.md @@ -0,0 +1,56 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). + +## Threat Model + +### Security Assumptions + +- The log file interpreting applications are hardened, as input is not sanitized +- No log events in the rust core are leaked to the frontend unless explicitly configured to output to the `TargetKind::Webview` component +- The log events generated in the frontend can be accessed from everywhere in the frontend +- There is no secret censoring inbuilt and developers need to take care of what they log in their application + +### Threats + +#### Secret Leakage + +One possible threat you need to consider when using this plugin is that secrets +in logs can theoretically be leaked when the application's frontend gets compromised. + +For this threat to be possible all of the following requirements need to be fulfilled: + +- `TargetKind::Webview` enabled OR secrets stem from frontend logs +- Frontend application is compromised via something like XSS (cross-site-scripting) OR logs are directly exposed +- Logs contain secrets or sensitive information + +If these requirements are not met, the leakage should not be possible. + +#### Out Of Scope + +- Any exploits on the log viewer/file viewer accessing the logs + +## Best Practices + +Do not log secrets or sensitive values in your logging and ensure that the upstream crates are not leaking such values in their logging events. +Ensure that logs are sanitized or trusted before opening them with third party tools. diff --git a/plugins/log/api-iife.js b/plugins/log/api-iife.js new file mode 100644 index 0000000..18637ed --- /dev/null +++ b/plugins/log/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_LOG__=function(e,n,o){"use strict";var r;async function a(e,o,r){const a=function(e){if(e){if(!e.startsWith("Error")){const n=e.split("\n").map(e=>e.split("@")).filter(([e,n])=>e.length>0&&"[native code]"!==n);return n[2]?.filter(e=>e.length>0).join("@")}{const n=e.split("\n"),o=n[3]?.trim();if(!o)return;const r=/at\s+(?.*?)\s+\((?.*?):(?\d+):(?\d+)\)/,a=o.match(r);if(a){const{functionName:e,fileName:n,lineNumber:o,columnNumber:r}=a.groups;return`${e}@${n}:${o}:${r}`}{const e=/at\s+(?.*?):(?\d+):(?\d+)/,n=o.match(e);if(n){const{fileName:e,lineNumber:o,columnNumber:r}=n.groups;return`@${e}:${o}:${r}`}}}}}((new Error).stack),{file:l,line:t,keyValues:i}=r??{};await n.invoke("plugin:log|log",{level:e,message:o,location:a,file:l,line:t,keyValues:i})}async function l(e){return await o.listen("log://log",n=>{const{level:o}=n.payload;let{message:r}=n.payload;r=r.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,""),e({message:r,level:o})})}return e.LogLevel=void 0,(r=e.LogLevel||(e.LogLevel={}))[r.Trace=1]="Trace",r[r.Debug=2]="Debug",r[r.Info=3]="Info",r[r.Warn=4]="Warn",r[r.Error=5]="Error",e.attachConsole=async function(){return await l(({level:n,message:o})=>{switch(n){case e.LogLevel.Trace:console.log(o);break;case e.LogLevel.Debug:console.debug(o);break;case e.LogLevel.Info:console.info(o);break;case e.LogLevel.Warn:console.warn(o);break;case e.LogLevel.Error:console.error(o);break;default:throw new Error(`unknown log level ${n}`)}})},e.attachLogger=l,e.debug=async function(n,o){await a(e.LogLevel.Debug,n,o)},e.error=async function(n,o){await a(e.LogLevel.Error,n,o)},e.info=async function(n,o){await a(e.LogLevel.Info,n,o)},e.trace=async function(n,o){await a(e.LogLevel.Trace,n,o)},e.warn=async function(n,o){await a(e.LogLevel.Warn,n,o)},e}({},window.__TAURI__.core,window.__TAURI__.event);Object.defineProperty(window.__TAURI__,"log",{value:__TAURI_PLUGIN_LOG__})} diff --git a/plugins/log/banner.png b/plugins/log/banner.png new file mode 100644 index 0000000..c72d62c Binary files /dev/null and b/plugins/log/banner.png differ diff --git a/plugins/log/build.rs b/plugins/log/build.rs new file mode 100644 index 0000000..5969c1e --- /dev/null +++ b/plugins/log/build.rs @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["log"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .ios_path("ios") + .build(); +} diff --git a/plugins/log/guest-js/index.ts b/plugins/log/guest-js/index.ts new file mode 100644 index 0000000..87537e2 --- /dev/null +++ b/plugins/log/guest-js/index.ts @@ -0,0 +1,344 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Configure the Tauri application log system, and access the JavaScript-side log functions. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn, type Event } from '@tauri-apps/api/event' + +/** + * Options to associate extra metadata with a log entry. + */ +export interface LogOptions { + /** The name of the file that emitted the log entry. Included in the log record's target when set. */ + file?: string + /** The line number in {@linkcode LogOptions.file} that emitted the log entry. */ + line?: number + /** Additional structured key-value pairs to attach to the log entry. */ + keyValues?: Record +} + +/** + * The verbosity level of a log entry, matching the levels of the Rust `log` crate. + */ +export enum LogLevel { + /** + * The "trace" level. + * + * Designates very low priority, often extremely verbose, information. + */ + Trace = 1, + /** + * The "debug" level. + * + * Designates lower priority information. + */ + Debug, + /** + * The "info" level. + * + * Designates useful information. + */ + Info, + /** + * The "warn" level. + * + * Designates hazardous situations. + */ + Warn, + /** + * The "error" level. + * + * Designates very serious errors. + */ + Error +} + +function getCallerLocation(stack?: string) { + if (!stack) { + return + } + + if (stack.startsWith('Error')) { + // Assume it's Chromium V8 + // + // Error + // at baz (filename.js:10:15) + // at bar (filename.js:6:3) + // at foo (filename.js:2:3) + // at filename.js:13:1 + + const lines = stack.split('\n') + // Find the third line (caller's caller of the current location) + const callerLine = lines[3]?.trim() + if (!callerLine) { + return + } + + const regex = + /at\s+(?.*?)\s+\((?.*?):(?\d+):(?\d+)\)/ + const match = callerLine.match(regex) + + if (match) { + const { functionName, fileName, lineNumber, columnNumber } = + match.groups as { + functionName: string + fileName: string + lineNumber: string + columnNumber: string + } + return `${functionName}@${fileName}:${lineNumber}:${columnNumber}` + } else { + // Handle cases where the regex does not match (e.g., last line without function name) + const regexNoFunction = + /at\s+(?.*?):(?\d+):(?\d+)/ + const matchNoFunction = callerLine.match(regexNoFunction) + if (matchNoFunction) { + const { fileName, lineNumber, columnNumber } = + matchNoFunction.groups as { + fileName: string + lineNumber: string + columnNumber: string + } + return `@${fileName}:${lineNumber}:${columnNumber}` + } + } + } else { + // Assume it's Webkit JavaScriptCore, example: + // + // baz@filename.js:10:24 + // bar@filename.js:6:6 + // foo@filename.js:2:6 + // global code@filename.js:13:4 + + const traces = stack.split('\n').map((line) => line.split('@')) + const filtered = traces.filter(([name, location]) => { + return name.length > 0 && location !== '[native code]' + }) + // Find the third line (caller's caller of the current location) + return filtered[2]?.filter((v) => v.length > 0).join('@') + } +} + +async function log( + level: LogLevel, + message: string, + options?: LogOptions +): Promise { + const location = getCallerLocation(new Error().stack) + + const { file, line, keyValues } = options ?? {} + + await invoke('plugin:log|log', { + level, + message, + location, + file, + line, + keyValues + }) +} + +/** + * Logs a message at the error level. + * + * @example + * ```typescript + * import { error } from '@tauri-apps/plugin-log'; + * + * const err_info = "No connection"; + * const port = 22; + * + * error(`Error: ${err_info} on port ${port}`); + * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 + */ +export async function error( + message: string, + options?: LogOptions +): Promise { + await log(LogLevel.Error, message, options) +} + +/** + * Logs a message at the warn level. + * + * @example + * ```typescript + * import { warn } from '@tauri-apps/plugin-log'; + * + * const warn_description = "Invalid Input"; + * + * warn(`Warning! {warn_description}!`); + * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 + */ +export async function warn( + message: string, + options?: LogOptions +): Promise { + await log(LogLevel.Warn, message, options) +} + +/** + * Logs a message at the info level. + * + * @example + * ```typescript + * import { info } from '@tauri-apps/plugin-log'; + * + * const conn_info = { port: 40, speed: 3.20 }; + * + * info(`Connected to port {conn_info.port} at {conn_info.speed} Mb/s`); + * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 + */ +export async function info( + message: string, + options?: LogOptions +): Promise { + await log(LogLevel.Info, message, options) +} + +/** + * Logs a message at the debug level. + * + * @example + * ```typescript + * import { debug } from '@tauri-apps/plugin-log'; + * + * const pos = { x: 3.234, y: -1.223 }; + * + * debug(`New position: x: {pos.x}, y: {pos.y}`); + * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 + */ +export async function debug( + message: string, + options?: LogOptions +): Promise { + await log(LogLevel.Debug, message, options) +} + +/** + * Logs a message at the trace level. + * + * @example + * ```typescript + * import { trace } from '@tauri-apps/plugin-log'; + * + * let pos = { x: 3.234, y: -1.223 }; + * + * trace(`Position is: x: {pos.x}, y: {pos.y}`); + * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 + */ +export async function trace( + message: string, + options?: LogOptions +): Promise { + await log(LogLevel.Trace, message, options) +} + +interface RecordPayload { + level: LogLevel + message: string +} + +type LoggerFn = (fn: RecordPayload) => void + +/** + * Attaches a listener for the log, and calls the passed function for each log entry. + * + * @example + * ```typescript + * import { attachLogger } from '@tauri-apps/plugin-log'; + * + * const detach = await attachLogger(({ level, message }) => { + * console.log(`[${level}] ${message}`); + * }); + * + * // detach the listener later + * detach(); + * ``` + * + * @param fn a function to call for every log entry emitted by the Rust side. + * @returns a promise resolving to a function to cancel the listener. + * @since 2.0.0 + */ +export async function attachLogger(fn: LoggerFn): Promise { + return await listen('log://log', (event: Event) => { + const { level } = event.payload + let { message } = event.payload + + // Strip ANSI escape codes + message = message.replace( + // TODO: Investigate security/detect-unsafe-regex + // eslint-disable-next-line no-control-regex, security/detect-unsafe-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ) + fn({ message, level }) + }) +} + +/** + * Attaches a listener that writes log entries to the console as they come in. + * + * @example + * ```typescript + * import { attachConsole } from '@tauri-apps/plugin-log'; + * + * const detach = await attachConsole(); + * + * // detach the listener later + * detach(); + * ``` + * + * @returns a promise resolving to a function to cancel the listener. + * @since 2.0.0 + */ +export async function attachConsole(): Promise { + return await attachLogger(({ level, message }: RecordPayload) => { + switch (level) { + case LogLevel.Trace: + console.log(message) + break + case LogLevel.Debug: + console.debug(message) + break + case LogLevel.Info: + console.info(message) + break + case LogLevel.Warn: + console.warn(message) + break + case LogLevel.Error: + console.error(message) + break + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`unknown log level ${level}`) + } + }) +} diff --git a/plugins/log/ios/.gitignore b/plugins/log/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/log/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/log/ios/Package.swift b/plugins/log/ios/Package.swift new file mode 100644 index 0000000..1571f22 --- /dev/null +++ b/plugins/log/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-log", + platforms: [ + .macOS(.v10_13), + .iOS(.v11), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-log", + type: .static, + targets: ["tauri-plugin-log"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-log", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/log/ios/README.md b/plugins/log/ios/README.md new file mode 100644 index 0000000..578d9ff --- /dev/null +++ b/plugins/log/ios/README.md @@ -0,0 +1,3 @@ +# Log + +Exposes a function log a message using the OSLog API. diff --git a/plugins/log/ios/Sources/LogPlugin.swift b/plugins/log/ios/Sources/LogPlugin.swift new file mode 100644 index 0000000..d1ced2c --- /dev/null +++ b/plugins/log/ios/Sources/LogPlugin.swift @@ -0,0 +1,18 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import SwiftRs +import Tauri +import UIKit +import os.log + +@_cdecl("tauri_log") +func log(level: Int, message: NSString) { + switch level { + case 1: Logger.debug(message as String) + case 2: Logger.info(message as String) + case 3: Logger.error(message as String) + default: break + } +} diff --git a/plugins/log/package.json b/plugins/log/package.json new file mode 100644 index 0000000..b21ff6c --- /dev/null +++ b/plugins/log/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-log", + "version": "2.9.2", + "description": "Configurable logging for your Tauri app.", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/log/permissions/autogenerated/commands/log.toml b/plugins/log/permissions/autogenerated/commands/log.toml new file mode 100644 index 0000000..ba36eff --- /dev/null +++ b/plugins/log/permissions/autogenerated/commands/log.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-log" +description = "Enables the log command without any pre-configured scope." +commands.allow = ["log"] + +[[permission]] +identifier = "deny-log" +description = "Denies the log command without any pre-configured scope." +commands.deny = ["log"] diff --git a/plugins/log/permissions/autogenerated/reference.md b/plugins/log/permissions/autogenerated/reference.md new file mode 100644 index 0000000..57d6c9f --- /dev/null +++ b/plugins/log/permissions/autogenerated/reference.md @@ -0,0 +1,43 @@ +## Default Permission + +Allows the log command + +#### This default permission set includes the following: + +- `allow-log` + +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`log:allow-log` + + + +Enables the log command without any pre-configured scope. + +
+ +`log:deny-log` + + + +Denies the log command without any pre-configured scope. + +
diff --git a/plugins/log/permissions/default.toml b/plugins/log/permissions/default.toml new file mode 100644 index 0000000..b2cb7c3 --- /dev/null +++ b/plugins/log/permissions/default.toml @@ -0,0 +1,4 @@ +"$schema" = "schemas/schema.json" +[default] +description = "Allows the log command" +permissions = ["allow-log"] diff --git a/plugins/log/permissions/schemas/schema.json b/plugins/log/permissions/schemas/schema.json new file mode 100644 index 0000000..cfee7e7 --- /dev/null +++ b/plugins/log/permissions/schemas/schema.json @@ -0,0 +1,318 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the log command without any pre-configured scope.", + "type": "string", + "const": "allow-log", + "markdownDescription": "Enables the log command without any pre-configured scope." + }, + { + "description": "Denies the log command without any pre-configured scope.", + "type": "string", + "const": "deny-log", + "markdownDescription": "Denies the log command without any pre-configured scope." + }, + { + "description": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`", + "type": "string", + "const": "default", + "markdownDescription": "Allows the log command\n#### This default permission set includes:\n\n- `allow-log`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/log/rollup.config.js b/plugins/log/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/log/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/log/src/commands.rs b/plugins/log/src/commands.rs new file mode 100644 index 0000000..e64c30a --- /dev/null +++ b/plugins/log/src/commands.rs @@ -0,0 +1,73 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::collections::HashMap; + +use log::RecordBuilder; + +use crate::{LogLevel, WEBVIEW_TARGET}; + +#[tauri::command] +pub fn log( + level: LogLevel, + message: String, + location: Option<&str>, + file: Option<&str>, + line: Option, + key_values: Option>, +) { + let level = log::Level::from(level); + + let target = if let Some(location) = location { + format!("{WEBVIEW_TARGET}::{location}") + } else { + WEBVIEW_TARGET.to_owned() + }; + + let mut builder = RecordBuilder::new(); + builder.level(level).target(&target).file(file).line(line); + + let key_values = key_values.unwrap_or_default(); + let mut kv = HashMap::new(); + for (k, v) in key_values.iter() { + kv.insert(k.as_str(), v.as_str()); + } + builder.key_values(&kv); + #[cfg(feature = "tracing")] + emit_trace(level, &message, location, file, line, &kv); + + log::logger().log(&builder.args(format_args!("{message}")).build()); +} + +// Target becomes default and location is added as a parameter +#[cfg(feature = "tracing")] +fn emit_trace( + level: log::Level, + message: &String, + location: Option<&str>, + file: Option<&str>, + line: Option, + kv: &HashMap<&str, &str>, +) { + macro_rules! emit_event { + ($level:expr) => { + tracing::event!( + target: WEBVIEW_TARGET, + $level, + message = %message, + location = location, + file, + line, + ?kv + ) + }; + } + match level { + log::Level::Error => emit_event!(tracing::Level::ERROR), + log::Level::Warn => emit_event!(tracing::Level::WARN), + log::Level::Info => emit_event!(tracing::Level::INFO), + log::Level::Debug => emit_event!(tracing::Level::DEBUG), + log::Level::Trace => emit_event!(tracing::Level::TRACE), + } +} diff --git a/plugins/log/src/lib.rs b/plugins/log/src/lib.rs new file mode 100644 index 0000000..18e8727 --- /dev/null +++ b/plugins/log/src/lib.rs @@ -0,0 +1,925 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Logging for Tauri applications. +//! +//! ## Cargo features +//! +//! - **colored**: Enables `Builder::with_colors`, which uses `fern`'s `colored` feature for ANSI-colored outputs. +//! - **tracing**: Emit both log and tracing for the JavaScript log commands. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use fern::{Filter, FormatCallback}; +use log::{LevelFilter, Record}; +use serde::Serialize; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::borrow::Cow; +use std::fs::OpenOptions; +use std::io::Write; +use std::{ + fmt::Arguments, + fs::{self, File}, + iter::FromIterator, + path::{Path, PathBuf}, +}; +use tauri::{ + plugin::{self, TauriPlugin}, + Manager, Runtime, +}; +use tauri::{AppHandle, Emitter}; +use time::{macros::format_description, OffsetDateTime}; + +pub use fern; +pub use log; + +mod commands; + +/// The log target prefix used for log records emitted through the JavaScript `trace`/`debug`/`info`/`warn`/`error` +/// bindings, optionally followed by `::{location}` when the caller's source location could be determined. +pub const WEBVIEW_TARGET: &str = "webview"; + +#[cfg(target_os = "ios")] +mod ios { + swift_rs::swift!(pub fn tauri_log( + level: u8, message: *const std::ffi::c_void + )); +} + +const DEFAULT_MAX_FILE_SIZE: u64 = 40_000; +const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne; +const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc; +const DEFAULT_FILE_OPEN_STRATEGY: FileOpenStrategy = FileOpenStrategy::Append; +const DEFAULT_LOG_TARGETS: [Target; 2] = [ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { file_name: None }), +]; +const LOG_DATE_FORMAT: &[time::format_description::FormatItem<'_>] = + format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]"); + +/// The error type returned by this plugin's fallible operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Forwarded from a call into the Tauri API, e.g. resolving the app log directory. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// Forwarded from a filesystem operation performed while creating, writing to or rotating a log file. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Forwarded from formatting a timestamp with the configured [`TimezoneStrategy`]. + #[error(transparent)] + TimeFormat(#[from] time::error::Format), + /// Forwarded from parsing an invalid `time` format description. + #[error(transparent)] + InvalidFormatDescription(#[from] time::error::InvalidFormatDescription), + /// Returned by [`Builder::split`] when [`Builder::skip_logger`] was called on the builder, since no logger was + /// created for it to return. + #[error("Internal logger disabled and cannot be acquired or attached")] + LoggerNotInitialized, +} + +/// An enum representing the available verbosity levels of the logger. +/// +/// It is very similar to the [`log::Level`], but serializes to unsigned ints instead of strings. +#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)] +#[repr(u16)] +pub enum LogLevel { + /// The "trace" level. + /// + /// Designates very low priority, often extremely verbose, information. + Trace = 1, + /// The "debug" level. + /// + /// Designates lower priority information. + Debug, + /// The "info" level. + /// + /// Designates useful information. + Info, + /// The "warn" level. + /// + /// Designates hazardous situations. + Warn, + /// The "error" level. + /// + /// Designates very serious errors. + Error, +} + +impl From for log::Level { + fn from(log_level: LogLevel) -> Self { + match log_level { + LogLevel::Trace => log::Level::Trace, + LogLevel::Debug => log::Level::Debug, + LogLevel::Info => log::Level::Info, + LogLevel::Warn => log::Level::Warn, + LogLevel::Error => log::Level::Error, + } + } +} + +impl From for LogLevel { + fn from(log_level: log::Level) -> Self { + match log_level { + log::Level::Trace => LogLevel::Trace, + log::Level::Debug => LogLevel::Debug, + log::Level::Info => LogLevel::Info, + log::Level::Warn => LogLevel::Warn, + log::Level::Error => LogLevel::Error, + } + } +} + +/// The strategy applied to a log file when it exceeds [`Builder::max_file_size`]. +#[derive(Debug, Clone)] +pub enum RotationStrategy { + /// Will keep all the logs, renaming them to include the date. + KeepAll, + /// Will only keep the most recent log up to its maximal size. + KeepOne, + /// Will keep some of the most recent logs, renaming them to include the date. + KeepSome(usize), +} + +/// The timezone used to compute the current time for log message timestamps and rotated log file names. +#[derive(Debug, Clone)] +pub enum TimezoneStrategy { + /// Use UTC time. + UseUtc, + /// Use the local timezone. Falls back to UTC if the local offset cannot be determined. + UseLocal, +} + +impl TimezoneStrategy { + /// Returns the current time according to this strategy. + pub fn get_now(&self) -> OffsetDateTime { + match self { + TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(), + TimezoneStrategy::UseLocal => { + OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()) + } // Fallback to UTC since Rust cannot determine local timezone + } + } +} + +/// The strategy used to open a log file when a [`TargetKind::Folder`] or [`TargetKind::LogDir`] target is set up. +#[derive(Debug, Clone, PartialEq)] +pub enum FileOpenStrategy { + /// Open existing file from last session and append, if any. + Append, + /// Create a new file on each session start, rotating the last session if any. + Rotate, +} + +/// A custom log writer that rotates the log file when it exceeds specified size. +struct RotatingFile { + dir: PathBuf, + file_name: String, + path: PathBuf, + /// Maximum file size before rotating in bytes + max_size: u64, + /// Current file size in bytes + current_size: u64, + rotation_strategy: RotationStrategy, + timezone_strategy: TimezoneStrategy, + file_open_strategy: FileOpenStrategy, + inner: Option, + buffer: Vec, +} + +impl RotatingFile { + pub fn new( + dir: impl AsRef, + file_name: String, + max_size: u64, + rotation_strategy: RotationStrategy, + timezone_strategy: TimezoneStrategy, + file_open_strategy: FileOpenStrategy, + ) -> Result { + let dir = dir.as_ref().to_path_buf(); + let path = dir.join(&file_name).with_extension("log"); + + let mut rotator = Self { + dir, + file_name, + path, + max_size, + current_size: 0, + rotation_strategy, + timezone_strategy, + file_open_strategy, + inner: None, + buffer: Vec::new(), + }; + + rotator.open_file()?; + if rotator.current_size >= rotator.max_size + || (rotator.current_size > 0 && rotator.file_open_strategy == FileOpenStrategy::Rotate) + { + rotator.rotate()?; + } + if let RotationStrategy::KeepSome(keep_count) = rotator.rotation_strategy { + rotator.remove_old_files(keep_count)?; + } + + Ok(rotator) + } + + fn open_file(&mut self) -> Result<(), Error> { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + self.current_size = file.metadata()?.len(); + self.inner = Some(file); + Ok(()) + } + + fn rotate(&mut self) -> Result<(), Error> { + if let Some(mut file) = self.inner.take() { + let _ = file.flush(); + } + if self.path.exists() { + match self.rotation_strategy { + RotationStrategy::KeepAll => { + self.rename_file_to_dated()?; + } + RotationStrategy::KeepSome(keep_count) => { + // remove_old_files excludes the active file. + // So we need to keep (keep_count - 1) archived files to make room for the one we are about to archive. + self.remove_old_files(keep_count - 1)?; + self.rename_file_to_dated()?; + } + RotationStrategy::KeepOne => { + fs::remove_file(&self.path)?; + } + } + } + self.open_file()?; + Ok(()) + } + + /// Remove old log files until the number of old log files is equal to the keep_count, + /// the current active log file is not included in the keep_count. + fn remove_old_files(&self, keep_count: usize) -> Result<(), Error> { + let mut files = fs::read_dir(&self.dir)? + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + let old_file_name = path.file_name()?.to_string_lossy().into_owned(); + if old_file_name.starts_with(&self.file_name) + // exclude the current active file + && old_file_name != format!("{}.log", self.file_name) + { + let date = old_file_name + .strip_prefix(&self.file_name)? + .strip_prefix("_")? + .strip_suffix(".log")?; + Some((path, date.to_string())) + } else { + None + } + }) + .collect::>(); + + files.sort_by(|a, b| a.1.cmp(&b.1)); + + if files.len() > keep_count { + let files_to_remove = files.len() - keep_count; + for (old_log_path, _) in files.iter().take(files_to_remove) { + fs::remove_file(old_log_path)?; + } + } + Ok(()) + } + + fn rename_file_to_dated(&self) -> Result<(), Error> { + let to = self.dir.join(format!( + "{}_{}.log", + self.file_name, + self.timezone_strategy + .get_now() + .format(LOG_DATE_FORMAT) + .unwrap(), + )); + if to.is_file() { + // designated rotated log file name already exists + // highly unlikely but defensively handle anyway by adding .bak to filename + let mut to_bak = to.clone(); + to_bak.set_file_name(format!( + "{}.bak", + to_bak.file_name().unwrap().to_string_lossy() + )); + fs::rename(&to, to_bak)?; + } + fs::rename(&self.path, &to)?; + Ok(()) + } +} + +impl Write for RotatingFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + if self.inner.is_none() { + self.open_file().map_err(std::io::Error::other)?; + } + + if self.current_size != 0 && self.current_size + (self.buffer.len() as u64) > self.max_size + { + self.rotate().map_err(std::io::Error::other)?; + } + + if let Some(file) = self.inner.as_mut() { + file.write_all(&self.buffer)?; + self.current_size += self.buffer.len() as u64; + file.flush()?; + } + self.buffer.clear(); + Ok(()) + } +} + +#[derive(Debug, Serialize, Clone)] +struct RecordPayload { + message: String, + level: LogLevel, +} + +/// An enum representing the available targets of the logger. +pub enum TargetKind { + /// Print logs to stdout. + Stdout, + /// Print logs to stderr. + Stderr, + /// Write logs to the given directory. + /// + /// The plugin will ensure the directory exists before writing logs. + Folder { + /// Path of the directory to write the log file to. + path: PathBuf, + /// Name of the log file, without extension. Defaults to the app's package name when `None`. + file_name: Option, + }, + /// Write logs to the OS specific logs directory. + /// + /// ### Platform-specific + /// + /// |Platform | Value | Example | + /// | --------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------- | + /// | Linux | `$XDG_DATA_HOME/{bundleIdentifier}/logs` or `$HOME/.local/share/{bundleIdentifier}/logs` | `/home/alice/.local/share/com.tauri.dev/logs` | + /// | macOS/iOS | `{homeDir}/Library/Logs/{bundleIdentifier}` | `/Users/Alice/Library/Logs/com.tauri.dev` | + /// | Windows | `{FOLDERID_LocalAppData}/{bundleIdentifier}/logs` | `C:\Users\Alice\AppData\Local\com.tauri.dev\logs` | + /// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` | + LogDir { + /// Name of the log file, without extension. Defaults to the app's package name when `None`. + file_name: Option, + }, + /// Forward logs to the webview (via the `log://log` event). + /// + /// This requires the webview to subscribe to log events, via this plugins `attachConsole` function. + Webview, + /// Send logs to a [`fern::Dispatch`] + /// + /// You can use this to construct arbitrary log targets. + Dispatch(fern::Dispatch), +} + +type Formatter = dyn Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static; + +/// A log target. +pub struct Target { + kind: TargetKind, + filters: Vec>, + formatter: Option>, +} + +impl Target { + /// Creates a new [`Target`] of the given [`TargetKind`], with no filters and the [`Builder`]'s formatter. + #[inline] + pub const fn new(kind: TargetKind) -> Self { + Self { + kind, + filters: Vec::new(), + formatter: None, + } + } + + /// Adds a filter that can reject log records from being sent to this target. Multiple filters may be added by + /// calling this method more than once. + #[inline] + pub fn filter(mut self, filter: F) -> Self + where + F: Fn(&log::Metadata) -> bool + Send + Sync + 'static, + { + self.filters.push(Box::new(filter)); + self + } + + /// Sets a formatter for log records sent to this target, overriding the [`Builder`]'s formatter for this + /// target only. + #[inline] + pub fn format(mut self, formatter: F) -> Self + where + F: Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static, + { + self.formatter.replace(Box::new(formatter)); + self + } +} + +/// Builds the log plugin, configuring targets, rotation, formatting and level filters before attaching the +/// resulting logger as the global logger for the [`log`] crate. +pub struct Builder { + dispatch: fern::Dispatch, + rotation_strategy: RotationStrategy, + timezone_strategy: TimezoneStrategy, + file_open_strategy: FileOpenStrategy, + max_file_size: u128, + targets: Vec, + is_skip_logger: bool, +} + +impl Default for Builder { + fn default() -> Self { + #[cfg(desktop)] + let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]"); + let dispatch = fern::Dispatch::new().format(move |out, message, record| { + out.finish( + #[cfg(mobile)] + format_args!("[{}] {}", record.target(), message), + #[cfg(desktop)] + format_args!( + "{}[{}][{}] {}", + DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(), + record.target(), + record.level(), + message + ), + ) + }); + Self { + dispatch, + rotation_strategy: DEFAULT_ROTATION_STRATEGY, + timezone_strategy: DEFAULT_TIMEZONE_STRATEGY, + file_open_strategy: DEFAULT_FILE_OPEN_STRATEGY, + max_file_size: DEFAULT_MAX_FILE_SIZE as u128, + targets: DEFAULT_LOG_TARGETS.into(), + is_skip_logger: false, + } + } +} + +impl Builder { + /// Creates a new [`Builder`] with the default configuration: targets [`TargetKind::Stdout`] and + /// [`TargetKind::LogDir`] (using the app's package name as the file name), [`RotationStrategy::KeepOne`], + /// [`TimezoneStrategy::UseUtc`], [`FileOpenStrategy::Append`], a maximum log file size of `40_000` bytes + /// (see [`Self::max_file_size`]) and the default `fern` formatter. + pub fn new() -> Self { + Default::default() + } + + /// Sets the [`RotationStrategy`]. + /// + /// Default is [`RotationStrategy::KeepOne`] + pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self { + self.rotation_strategy = rotation_strategy; + self + } + + /// Sets the [`TimezoneStrategy`]. + /// Calling this method overrides the format set in [`Self::format`]. + /// + /// Default is [`TimezoneStrategy::UseUtc`] + pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self { + self.timezone_strategy = timezone_strategy.clone(); + + let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]"); + self.dispatch = self.dispatch.format(move |out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + timezone_strategy.get_now().format(&format).unwrap(), + record.level(), + record.target(), + message + )) + }); + self + } + + /// Sets the strategy to open the log file. + /// + /// The default is [`FileOpenStrategy::Append`]. + pub fn file_open_strategy(mut self, file_open_strategy: FileOpenStrategy) -> Self { + self.file_open_strategy = file_open_strategy; + self + } + + /// Sets the maximum file size in bytes for log rotation. + /// + /// Values larger than [`u64::MAX`] will be clamped to [`u64::MAX`]. + /// In v3, this parameter will be changed to `u64`. + /// + /// Default is `40_000` + pub fn max_file_size(mut self, max_file_size: u128) -> Self { + self.max_file_size = max_file_size.min(u64::MAX as u128); + self + } + + /// Clears the format so that only the message is logged. + /// + /// e.g. `log::info!("message")` will log out `message` + pub fn clear_format(mut self) -> Self { + self.dispatch = self.dispatch.format(|out, message, _record| { + out.finish(format_args!("{message}")); + }); + self + } + + /// Sets the formatter of this dispatch. The closure should accept a + /// callback, a message and a log record, and write the resulting + /// format to the writer. + /// + /// The log record is passed for completeness, but the `args()` method of + /// the record should be ignored, and the [`std::fmt::Arguments`] given + /// should be used instead. `record.args()` may be used to retrieve the + /// _original_ log message, but in order to allow for true log + /// chaining, formatters should use the given message instead whenever + /// including the message in the output. + /// + /// To avoid all allocation of intermediate results, the formatter is + /// "completed" by calling a callback, which then calls the rest of the + /// logging chain with the new formatted message. The callback object keeps + /// track of if it was called or not via a stack boolean as well, so if + /// you don't use `out.finish` the log message will continue down + /// the logger chain unformatted. + /// + /// Example usage: + /// + /// ``` + /// tauri_plugin_log::Builder::new() + /// .format(|out, message, record| { + /// out.finish(format_args!( + /// "[{} {}] {}", + /// record.level(), + /// record.target(), + /// message + /// )) + /// }); + /// ``` + pub fn format(mut self, formatter: F) -> Self + where + F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static, + { + self.dispatch = self.dispatch.format(formatter); + self + } + + /// Sets the overarching level filter for this logger. + /// All messages not already filtered by something set by [`Self::level_for`] will be affected. + /// + /// All messages filtered will be discarded if less severe than the given level. + /// + /// Default level is [`log::LevelFilter::Trace`]. + pub fn level(mut self, level_filter: impl Into) -> Self { + self.dispatch = self.dispatch.level(level_filter.into()); + self + } + + /// Sets a per-target log level filter. Default target for log messages is + /// `crate_name::module_name` or + /// `crate_name` for logs in the crate root. Targets can also be set with + /// `info!(target: "target-name", ...)`. + /// + /// For each log record fern will first try to match the most specific + /// level_for, and then progressively more general ones until either a + /// matching level is found, or the default level is used. + /// + /// For example, a log for the target `hyper::http::h1` will first test a + /// level_for for `hyper::http::h1`, then for `hyper::http`, then for + /// `hyper`, then use the default level. + /// + /// Examples: + /// + /// A program wants to include a lot of debugging output, but the library + /// "hyper" is known to work well, so debug output from it should be + /// excluded: + /// + /// ``` + /// # fn main() { + /// tauri_plugin_log::Builder::new() + /// .level(log::LevelFilter::Trace) + /// .level_for("hyper", log::LevelFilter::Info) + /// # ; + /// # } + /// ``` + pub fn level_for(mut self, module: impl Into>, level: LevelFilter) -> Self { + self.dispatch = self.dispatch.level_for(module, level); + self + } + + /// Adds a custom filter which can reject messages passing through this logger. + /// + /// [`Self::level`] and [`Self::level_for`] are preferred if applicable. + /// + /// Example usage: + /// + /// ``` + /// tauri_plugin_log::Builder::new() + /// .level(log::LevelFilter::Info) + /// .filter(|metadata| { + /// // Reject messages with the `Error` log level. + /// metadata.level() != log::Level::Error + /// }); + /// ``` + pub fn filter(mut self, filter: F) -> Self + where + F: Fn(&log::Metadata) -> bool + Send + Sync + 'static, + { + self.dispatch = self.dispatch.filter(filter); + self + } + + /// Removes all targets. Useful to ignore the default targets and reconfigure them. + pub fn clear_targets(mut self) -> Self { + self.targets.clear(); + self + } + + /// Adds a log target to the logger. + /// + /// ```rust + /// use tauri_plugin_log::{Target, TargetKind}; + /// tauri_plugin_log::Builder::new() + /// .target(Target::new(TargetKind::Webview)); + /// ``` + /// + /// The default targets are + /// + /// ```rust + /// # use tauri_plugin_log::{Target, TargetKind, Builder}; + /// # Builder::new() + /// # .targets( + /// [ + /// Target::new(TargetKind::Stdout), + /// Target::new(TargetKind::LogDir { file_name: None }), + /// ] + /// # ); + /// ``` + pub fn target(mut self, target: Target) -> Self { + self.targets.push(target); + self + } + + /// Skip the creation and global registration of a logger + /// + /// If you wish to use your own global logger, you must call `skip_logger` so that the plugin does not attempt to set a second global logger. + /// In this configuration, no logger will be created and the plugin's `log` command will rely on the result of `log::logger()`. + /// You will be responsible for configuring the logger yourself and any included targets will be ignored. + /// If ever initializing the plugin multiple times, such as if registering the plugin while testing, call this method to avoid panicking when registering multiple loggers. + /// For interacting with `tracing`, you can leverage the `tracing-log` logger to forward logs to `tracing` or enable the `tracing` feature for this plugin to emit events directly to the tracing system. + /// Both scenarios require calling this method. + /// + /// ```rust + /// # struct SimpleLogger; + /// # impl log::Log for SimpleLogger { + /// # fn enabled(&self, _metadata: &log::Metadata) -> bool { true } + /// # fn log(&self, _record: &log::Record) {} + /// # fn flush(&self) {} + /// # } + /// static LOGGER: SimpleLogger = SimpleLogger; + /// + /// log::set_logger(&LOGGER).expect("a logger was already set"); + /// log::set_max_level(log::LevelFilter::Info); + /// tauri_plugin_log::Builder::new() + /// .skip_logger(); + /// ``` + pub fn skip_logger(mut self) -> Self { + self.is_skip_logger = true; + self + } + + /// Replaces the targets of the logger. + /// + /// ```rust + /// use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET}; + /// tauri_plugin_log::Builder::new() + /// .targets([ + /// Target::new(TargetKind::Webview), + /// Target::new(TargetKind::LogDir { file_name: Some("webview".into()) }).filter(|metadata| metadata.target().starts_with(WEBVIEW_TARGET)), + /// Target::new(TargetKind::LogDir { file_name: Some("rust".into()) }).filter(|metadata| !metadata.target().starts_with(WEBVIEW_TARGET)), + /// ]); + /// ``` + /// + /// The default targets are + /// + /// ```rust + /// # use tauri_plugin_log::{Target, TargetKind, Builder}; + /// # Builder::new() + /// # .targets( + /// [ + /// Target::new(TargetKind::Stdout), + /// Target::new(TargetKind::LogDir { file_name: None }), + /// ] + /// # ); + /// ``` + pub fn targets(mut self, targets: impl IntoIterator) -> Self { + self.targets = Vec::from_iter(targets); + self + } + + #[cfg(feature = "colored")] + pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self { + let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]"); + + let timezone_strategy = self.timezone_strategy.clone(); + self.format(move |out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + timezone_strategy.get_now().format(&format).unwrap(), + colors.color(record.level()), + record.target(), + message + )) + }) + } + + fn acquire_logger( + app_handle: &AppHandle, + mut dispatch: fern::Dispatch, + rotation_strategy: RotationStrategy, + timezone_strategy: TimezoneStrategy, + file_open_strategy: FileOpenStrategy, + max_file_size: u64, + targets: Vec, + ) -> Result<(log::LevelFilter, Box), Error> { + let app_name = &app_handle.package_info().name; + + // setup targets + for target in targets { + let mut target_dispatch = fern::Dispatch::new(); + for filter in target.filters { + target_dispatch = target_dispatch.filter(filter); + } + if let Some(formatter) = target.formatter { + target_dispatch = target_dispatch.format(formatter); + } + + let logger = match target.kind { + #[cfg(target_os = "android")] + TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log), + #[cfg(target_os = "ios")] + TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| { + let message = format!("{}", record.args()); + unsafe { + ios::tauri_log( + match record.level() { + log::Level::Trace | log::Level::Debug => 1, + log::Level::Info => 2, + log::Level::Warn | log::Level::Error => 3, + }, + // The string is allocated in rust, so we must + // autorelease it rust to give it to the Swift + // runtime. + objc2::rc::Retained::autorelease_ptr( + objc2_foundation::NSString::from_str(message.as_str()), + ) as _, + ); + } + }), + #[cfg(desktop)] + TargetKind::Stdout => std::io::stdout().into(), + #[cfg(desktop)] + TargetKind::Stderr => std::io::stderr().into(), + TargetKind::Folder { path, file_name } => { + if !path.exists() { + fs::create_dir_all(&path)?; + } + + let rotator = RotatingFile::new( + &path, + file_name.unwrap_or(app_name.clone()), + max_file_size, + rotation_strategy.clone(), + timezone_strategy.clone(), + file_open_strategy.clone(), + )?; + fern::Output::writer(Box::new(rotator), "\n") + } + TargetKind::LogDir { file_name } => { + let path = app_handle.path().app_log_dir()?; + if !path.exists() { + fs::create_dir_all(&path)?; + } + + let rotator = RotatingFile::new( + &path, + file_name.unwrap_or(app_name.clone()), + max_file_size, + rotation_strategy.clone(), + timezone_strategy.clone(), + file_open_strategy.clone(), + )?; + fern::Output::writer(Box::new(rotator), "\n") + } + TargetKind::Webview => { + let app_handle = app_handle.clone(); + + fern::Output::call(move |record| { + let payload = RecordPayload { + message: record.args().to_string(), + level: record.level().into(), + }; + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = app_handle.emit("log://log", payload); + }); + }) + } + TargetKind::Dispatch(dispatch) => dispatch.into(), + }; + target_dispatch = target_dispatch.chain(logger); + + dispatch = dispatch.chain(target_dispatch); + } + + Ok(dispatch.into_log()) + } + + fn plugin_builder() -> plugin::Builder { + plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log]) + } + + /// Builds the [`TauriPlugin`] and returns it together with the configured [`log::LevelFilter`] and [`log::Log`] + /// implementation, instead of registering them as the global logger. + /// + /// Use this instead of [`Self::build`] when this logger needs to be combined with another one, e.g. when + /// forwarding logs to `tracing` via `tracing-log`, since only a single global logger can be installed. + /// + /// # Errors + /// + /// Returns [`Error::LoggerNotInitialized`] if [`Self::skip_logger`] was called on this builder. + #[allow(clippy::type_complexity)] + pub fn split( + self, + app_handle: &AppHandle, + ) -> Result<(TauriPlugin, log::LevelFilter, Box), Error> { + if self.is_skip_logger { + return Err(Error::LoggerNotInitialized); + } + let plugin = Self::plugin_builder(); + let (max_level, log) = Self::acquire_logger( + app_handle, + self.dispatch, + self.rotation_strategy, + self.timezone_strategy, + self.file_open_strategy, + self.max_file_size as u64, + self.targets, + )?; + + Ok((plugin.build(), max_level, log)) + } + + /// Builds the [`TauriPlugin`], attaching the configured logger as the global logger for the [`log`] crate on + /// setup, unless [`Self::skip_logger`] was called on this builder. + pub fn build(self) -> TauriPlugin { + Self::plugin_builder() + .setup(move |app_handle, _api| { + if !self.is_skip_logger { + let (max_level, log) = Self::acquire_logger( + app_handle, + self.dispatch, + self.rotation_strategy, + self.timezone_strategy, + self.file_open_strategy, + self.max_file_size as u64, + self.targets, + )?; + attach_logger(max_level, log)?; + } + Ok(()) + }) + .build() + } +} + +/// Attaches the given logger +pub fn attach_logger( + max_level: log::LevelFilter, + log: Box, +) -> Result<(), log::SetLoggerError> { + log::set_boxed_logger(log)?; + log::set_max_level(max_level); + Ok(()) +} diff --git a/plugins/log/tsconfig.json b/plugins/log/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/log/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/mirrors.txt b/plugins/mirrors.txt new file mode 100644 index 0000000..d346da1 --- /dev/null +++ b/plugins/mirrors.txt @@ -0,0 +1,20 @@ +autostart +cli +clipboard-manager +dialog +fs +global-shortcut +http +log +notification +os +positioner +process +shell +sql +store +stronghold +updater +upload +websocket +window-state diff --git a/plugins/nfc/CHANGELOG.md b/plugins/nfc/CHANGELOG.md new file mode 100644 index 0000000..723981e --- /dev/null +++ b/plugins/nfc/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +## [2.3.6] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`52672616`](https://github.com/tauri-apps/plugins-workspace/commit/526726162f8ddddf9469797436123704a261df87) ([#3419](https://github.com/tauri-apps/plugins-workspace/pull/3419) by [@pascalauroboa](https://github.com/tauri-apps/plugins-workspace/../../pascalauroboa)) Expect a `NFCTag` as input and return a `ScanResponse` in the `scan` function. + +## \[2.3.5] + +- [`ab037b70`](https://github.com/tauri-apps/plugins-workspace/commit/ab037b70d6fb54f1ad07d5fe21c41bd1997b34dd) ([#3339](https://github.com/tauri-apps/plugins-workspace/pull/3339) by [@crazy-genius](https://github.com/tauri-apps/plugins-workspace/../../crazy-genius)) Fixed syntax error in kotlin files potentially causing issues when trying to write to tags when no prior session existed. + +## \[2.3.4] + +- [`b4348cee`](https://github.com/tauri-apps/plugins-workspace/commit/b4348cee926ee3d1db151bc831cdb9049bee717f) ([#3101](https://github.com/tauri-apps/plugins-workspace/pull/3101) by [@bclarke123](https://github.com/tauri-apps/plugins-workspace/../../bclarke123)) Update return value of `isAvailable` to match TypeScript function signature + +## \[2.3.3] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.2] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. + +## \[2.3.1] + +- [`fe23a5e0`](https://github.com/tauri-apps/plugins-workspace/commit/fe23a5e01399a6ad61426bf8a94a6bb97227cf88) ([#2885](https://github.com/tauri-apps/plugins-workspace/pull/2885) by [@zaphim12](https://github.com/tauri-apps/plugins-workspace/../../zaphim12)) On iOS, the reader session will now get closed properly on errors, preventing dangling invalid sessions that could prevent subsequent write attempts. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.1] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Add permissions. + +## \[2.0.0-alpha.0] + +- [`fe79adb`](https://github.com/tauri-apps/plugins-workspace/commit/fe79adb5c7febd0e912efb5581264d671709fbb0)([#830](https://github.com/tauri-apps/plugins-workspace/pull/830)) Initial release. + commit/fe79adb5c7febd0e912efb5581264d671709fbb0)([#830](https://github.com/tauri-apps/plugins-workspace/pull/830)) Initial release. + 30]\(https://github.com/tauri-apps/plugins-workspace/pull/830)) Initial release. + commit/fe79adb5c7febd0e912efb5581264d671709fbb0)([#830](https://github.com/tauri-apps/plugins-workspace/pull/830)) Initial release. + . + commit/fe79adb5c7febd0e912efb5581264d671709fbb0)([#830](https://github.com/tauri-apps/plugins-workspace/pull/830)) Initial release. + ithub.com/tauri-apps/plugins-workspace/pull/830)) Initial release. diff --git a/plugins/nfc/Cargo.toml b/plugins/nfc/Cargo.toml new file mode 100644 index 0000000..c0e7ed0 --- /dev/null +++ b/plugins/nfc/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tauri-plugin-nfc" +version = "2.3.6" +description = "Read and write NFC tags on Android and iOS." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-nfc" +exclude = ["/guest-js", "/package.json", "/rollup.config.js", "/tsconfig.json"] + +[package.metadata.docs.rs] +targets = ["x86_64-linux-android"] + +[package.metadata.platforms.support] +windows = { level = "none", notes = "" } +linux = { level = "none", notes = "" } +macos = { level = "none", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +serde_repr = "0.1" diff --git a/plugins/nfc/LICENSE.spdx b/plugins/nfc/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/nfc/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/nfc/LICENSE_APACHE-2.0 b/plugins/nfc/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/nfc/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/nfc/LICENSE_MIT b/plugins/nfc/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/nfc/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/nfc/README.md b/plugins/nfc/README.md new file mode 100644 index 0000000..ef1fa6f --- /dev/null +++ b/plugins/nfc/README.md @@ -0,0 +1,112 @@ +![NFC](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/nfc/banner.png) + +Read and write NFC tags on Android and iOS. + +| Platform | Supported | +| -------- | --------- | +| Linux | x | +| Windows | x | +| macOS | x | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.65**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-nfc = "2.0.0" +# alternatively with Git: +tauri-plugin-nfc = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + + + +```sh +pnpm add @tauri-apps/plugin-nfc +# or +npm add @tauri-apps/plugin-nfc +# or +yarn add @tauri-apps/plugin-nfc +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_nfc::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { scan, textRecord, write } from '@tauri-apps/plugin-nfc' +await scan({ type: 'tag', keepSessionAlive: true }) +await write([textRecord('Tauri is awesome!')]) +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Contributed By + + + + + + + + +
+ + CrabNebula + + + + Impierce + +
+ +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/nfc/SECURITY.md b/plugins/nfc/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/nfc/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/nfc/android/.gitignore b/plugins/nfc/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/nfc/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/nfc/android/build.gradle.kts b/plugins/nfc/android/build.gradle.kts new file mode 100644 index 0000000..bf691ef --- /dev/null +++ b/plugins/nfc/android/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.nfc" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/nfc/android/consumer-rules.pro b/plugins/nfc/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/nfc/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/nfc/android/settings.gradle b/plugins/nfc/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/nfc/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/nfc/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/nfc/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f0f7b66 --- /dev/null +++ b/plugins/nfc/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.nfc + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("app.tauri.nfc", appContext.packageName) + } +} diff --git a/plugins/nfc/android/src/main/AndroidManifest.xml b/plugins/nfc/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7603a35 --- /dev/null +++ b/plugins/nfc/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/nfc/android/src/main/java/NfcPlugin.kt b/plugins/nfc/android/src/main/java/NfcPlugin.kt new file mode 100644 index 0000000..04649b9 --- /dev/null +++ b/plugins/nfc/android/src/main/java/NfcPlugin.kt @@ -0,0 +1,519 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.nfc + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentFilter +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import android.nfc.tech.NdefFormatable +import android.os.Build +import android.os.Parcelable +import android.os.PatternMatcher +import android.webkit.WebView +import app.tauri.Logger +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import org.json.JSONArray +import java.io.IOException +import kotlin.concurrent.thread + +sealed class NfcAction { + object Read : NfcAction() + data class Write(val message: NdefMessage) : NfcAction() +} + +@InvokeArg +class UriFilter { + var scheme: String? = null + var host: String? = null + var pathPrefix: String? = null +} + +@InvokeArg +enum class TechKind(@JsonValue val value: String) { + IsoDep("IsoDep"), + MifareClassic("MifareClassic"), + MifareUltralight("MifareUltralight"), + Ndef("Ndef"), + NdefFormatable("NdefFormatable"), + NfcA("NfcA"), + NfcB("NfcB"), + NfcBarcode("NfcBarcode"), + NfcF("NfcF"), + NfcV("NfcV"); + + fun className(): String { + return when (this) { + IsoDep -> { + android.nfc.tech.IsoDep::class.java.name + } + MifareClassic -> { + android.nfc.tech.MifareClassic::class.java.name + } + MifareUltralight -> { + android.nfc.tech.MifareUltralight::class.java.name + } + Ndef -> { + android.nfc.tech.Ndef::class.java.name + } + NdefFormatable -> { + android.nfc.tech.NdefFormatable::class.java.name + } + NfcA -> { + android.nfc.tech.NfcA::class.java.name + } + NfcB -> { + android.nfc.tech.NfcB::class.java.name + } + NfcBarcode -> { + android.nfc.tech.NfcBarcode::class.java.name + } + NfcF -> { + android.nfc.tech.NfcF::class.java.name + } + NfcV -> { + android.nfc.tech.NfcV::class.java.name + } + } + } +} + +private fun addDataFilters(intentFilter: IntentFilter, uri: UriFilter?, mimeType: String?) { + uri?.let { it -> { + it.scheme?.let { + intentFilter.addDataScheme(it) + } + it.host?.let { + intentFilter.addDataAuthority(it, null) + } + it.pathPrefix?.let { + intentFilter.addDataPath(it, PatternMatcher.PATTERN_PREFIX) + } + }} + mimeType?.let { + intentFilter.addDataType(it) + } +} + +@InvokeArg +@JsonDeserialize(using = ScanKindDeserializer::class) +sealed class ScanKind { + @JsonDeserialize + class Tag: ScanKind() { + var mimeType: String? = null + var uri: UriFilter? = null + } + @JsonDeserialize + class Ndef: ScanKind() { + var mimeType: String? = null + var uri: UriFilter? = null + var techLists: Array>? = null + } + + fun filters(): Array? { + return when (this) { + is Tag -> { + val intentFilter = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + addDataFilters(intentFilter, uri, mimeType) + arrayOf(intentFilter) + } + is Ndef -> { + val intentFilter = IntentFilter(if (techLists == null) NfcAdapter.ACTION_NDEF_DISCOVERED else NfcAdapter.ACTION_TECH_DISCOVERED) + addDataFilters(intentFilter, uri, mimeType) + arrayOf(intentFilter) + } + } + } + + fun techLists(): Array>? { + return when (this) { + is Tag -> null + is Ndef -> { + techLists?.let { + val techs = mutableListOf>() + for (techList in it) { + val list = mutableListOf() + for (tech in techList) { + list.add(tech.className()) + } + techs.add(list.toTypedArray()) + } + techs.toTypedArray() + } ?: run { + null + } + } + } + } +} + +internal class ScanKindDeserializer: JsonDeserializer() { + override fun deserialize( + jsonParser: JsonParser, + deserializationContext: DeserializationContext + ): ScanKind { + val node: JsonNode = jsonParser.codec.readTree(jsonParser) + node.get("tag")?.let { + return jsonParser.codec.treeToValue(it, ScanKind.Tag::class.java) + } ?: node.get("ndef")?.let { + return jsonParser.codec.treeToValue(it, ScanKind.Ndef::class.java) + } ?: run { + throw Error("unknown scan kind $node") + } + } +} + +@InvokeArg +class ScanOptions { + lateinit var kind: ScanKind + var keepSessionAlive: Boolean = false +} + +@InvokeArg +class NDEFRecordData { + var format: Short = 0 + var kind: ByteArray = ByteArray(0) + var id: ByteArray = ByteArray(0) + var payload: ByteArray = ByteArray(0) +} + +@InvokeArg +class WriteOptions { + var kind: ScanKind? = null + lateinit var records: Array +} + +class Session( + val action: NfcAction, + val invoke: Invoke, + val keepAlive: Boolean, + var tag: Tag? = null, + val filters: Array? = null, + val techLists: Array>? = null +) + +@TauriPlugin +class NfcPlugin(private val activity: Activity) : Plugin(activity) { + private lateinit var webView: WebView + + private var nfcAdapter: NfcAdapter? = null + private var session: Session? = null + + override fun load(webView: WebView) { + super.load(webView) + this.webView = webView + this.nfcAdapter = NfcAdapter.getDefaultAdapter(activity.applicationContext) + } + + override fun onNewIntent(intent: Intent) { + Logger.info("NFC", "onNewIntent") + super.onNewIntent(intent) + + val extraTag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + + extraTag?.let { tag -> + session?.let { + if (it.keepAlive) { + it.tag = tag + } + } + + when (session?.action) { + is NfcAction.Read -> readTag(tag, intent) + is NfcAction.Write -> thread { + if (session?.action is NfcAction.Write) { + try { + writeTag(tag, (session?.action as NfcAction.Write).message) + session?.invoke?.resolve() + } catch (e: Exception) { + session?.invoke?.reject(e.toString()) + } finally { + if (this.session?.keepAlive != true) { + this.session = null + disableNFCInForeground() + } + } + } + } + + else -> {} + } + } + + } + + override fun onPause() { + disableNFCInForeground() + super.onPause() + Logger.info("NFC", "onPause") + } + + override fun onResume() { + super.onResume() + Logger.info("NFC", "onResume") + session?.let { + enableNFCInForeground(it.filters, it.techLists) + } + } + + private fun isAvailable(): Pair { + val available: Boolean + var errorReason: String? = null + + if (this.nfcAdapter === null) { + available = false + errorReason = "Device does not have NFC capabilities" + } else if (this.nfcAdapter?.isEnabled == false) { + available = false + errorReason = "NFC is disabled in device settings" + } else { + available = true + } + + return Pair(available, errorReason) + } + + @Command + fun isAvailable(invoke: Invoke) { + val ret = JSObject() + ret.put("available", isAvailable().first) + invoke.resolve(ret) + } + + @Command + fun scan(invoke: Invoke) { + val status = isAvailable() + if (!status.first) { + invoke.reject("NFC unavailable: " + status.second) + return + } + + val args = invoke.parseArgs(ScanOptions::class.java) + + val filters = args.kind.filters() + val techLists = args.kind.techLists() + enableNFCInForeground(filters, techLists) + + session = Session(NfcAction.Read, invoke, args.keepSessionAlive, null, filters, techLists) + } + + @Command + fun write(invoke: Invoke) { + val status = isAvailable() + if (!status.first) { + invoke.reject("NFC unavailable: " + status.second) + return + } + + val args = invoke.parseArgs(WriteOptions::class.java) + + val ndefRecords: MutableList = ArrayList() + for (record in args.records) { + ndefRecords.add(NdefRecord(record.format, record.kind, record.id, record.payload)) + } + + val message = NdefMessage(ndefRecords.toTypedArray()) + + session?.let { session -> + session.tag?.let { + try { + writeTag(it, message) + invoke.resolve() + } catch (e: Exception) { + invoke.reject(e.toString()) + } finally { + if (this.session?.keepAlive != true) { + this.session = null + disableNFCInForeground() + } + } + } ?: run { + invoke.reject("connected tag not found, please wait for it to be available and then call write()") + } + } ?: run { + args.kind?.let { kind -> + val filters = kind.filters() + val techLists = kind.techLists() + enableNFCInForeground(filters, techLists) + session = Session(NfcAction.Write(message), invoke, true, null, filters, techLists) + Logger.warn("NFC", "Write Mode Enabled") + } ?: run { + invoke.reject("Missing `kind` for write") + } + + } + } + + private fun readTag(tag: Tag, intent: Intent) { + try { + val rawMessages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, Parcelable::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + } + + when (intent.action) { + NfcAdapter.ACTION_NDEF_DISCOVERED -> { + // For some reason this one never triggers. + Logger.info("NFC", "new NDEF intent") + readTagInner(tag, rawMessages) + } + NfcAdapter.ACTION_TECH_DISCOVERED -> { + // For some reason this always triggers instead of NDEF_DISCOVERED even though we set ndef filters right now + Logger.info("NFC", "new TECH intent") + // TODO: handle different techs. Don't assume ndef. + readTagInner(tag, rawMessages) + } + NfcAdapter.ACTION_TAG_DISCOVERED -> { + // This should never trigger when an app handles NDEF and TECH + // TODO: Don't assume ndef. + readTagInner(tag, rawMessages) + } + } + } catch (e: Exception) { + session?.invoke?.reject("failed to read tag", e) + } finally { + if (this.session?.keepAlive != true) { + this.session = null + } + // TODO this crashes? disableNFCInForeground() + } + } + + private fun readTagInner(tag: Tag?, rawMessages: Array?) { + val ndefMessage = rawMessages?.get(0) as NdefMessage? + + val records = ndefMessage?.records ?: arrayOf() + + val jsonRecords = Array(records.size) { i -> recordToJson(records[i]) } + + val ret = JSObject() + if (tag !== null) { + ret.put("id", fromU8Array(tag.id)) + // TODO There's also ndef.type which returns the ndef spec type which may be interesting to know too? + ret.put("kind", JSArray.from(tag.techList)) + } + ret.put("records", JSArray.from(jsonRecords)) + + session?.invoke?.resolve(ret) + } + + private fun writeTag(tag: Tag, message: NdefMessage) { + // This should return tags that are already in ndef format + val ndefTag = Ndef.get(tag) + if (ndefTag !== null) { + // We have to connect first to check maxSize. + try { + ndefTag.connect() + } catch (e: IOException) { + throw Exception("Couldn't connect to NFC tag", e) + } + + if (ndefTag.maxSize < message.toByteArray().size) { + throw Exception("The message is too large for the provided NFC tag") + } else if (!ndefTag.isWritable) { + throw Exception("NFC tag is read-only") + } else { + try { + ndefTag.writeNdefMessage(message) + } catch (e: Exception) { + throw Exception("Couldn't write message to NFC tag", e) + } + } + + try { + ndefTag.close() + } catch (e: IOException) { + Logger.error("failed to close tag", e) + } + + return + } + + // This should cover tags that are not yet in ndef format but can be converted + val ndefFormatableTag = NdefFormatable.get(tag) + if (ndefFormatableTag !== null) { + try { + ndefFormatableTag.connect() + ndefFormatableTag.format(message) + } catch (e: Exception) { + throw Exception("Couldn't format tag as Ndef", e) + } + + try { + ndefFormatableTag.close() + } catch (e: IOException) { + Logger.error("failed to close tag", e) + } + + return + } + + // if we get to this line, the tag was neither Ndef nor NdefFormatable compatible + throw Exception("Tag doesn't support Ndef format") + } + + // TODO: Use ReaderMode instead of ForegroundDispatch + private fun enableNFCInForeground(filters: Array?, techLists: Array>?) { + val flag = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getActivity( + activity, 0, + Intent( + activity, + activity.javaClass + ).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + flag + ) + + nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, filters, techLists) + } + + private fun disableNFCInForeground() { + activity.runOnUiThread { + nfcAdapter?.disableForegroundDispatch(activity) + } + } +} + +private fun fromU8Array(byteArray: ByteArray): JSONArray { + val json = JSONArray() + for (byte in byteArray) { + json.put(byte) + } + return json +} + +private fun recordToJson(record: NdefRecord): JSObject { + val json = JSObject() + json.put("tnf", record.tnf) + json.put("kind", fromU8Array(record.type)) + json.put("id", fromU8Array(record.id)) + json.put("payload", fromU8Array(record.payload)) + return json +} \ No newline at end of file diff --git a/plugins/nfc/android/src/main/res/xml/nfc_tech_filter.xml b/plugins/nfc/android/src/main/res/xml/nfc_tech_filter.xml new file mode 100644 index 0000000..994905a --- /dev/null +++ b/plugins/nfc/android/src/main/res/xml/nfc_tech_filter.xml @@ -0,0 +1,13 @@ + + + android.nfc.tech.IsoDep + android.nfc.tech.NfcA + android.nfc.tech.NfcB + android.nfc.tech.NfcF + android.nfc.tech.NfcV + android.nfc.tech.Ndef + android.nfc.tech.NdefFormatable + android.nfc.tech.MifareClassic + android.nfc.tech.MifareUltralight + + diff --git a/plugins/nfc/android/src/test/java/ExampleUnitTest.kt b/plugins/nfc/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..2af426f --- /dev/null +++ b/plugins/nfc/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.nfc + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/nfc/api-iife.js b/plugins/nfc/api-iife.js new file mode 100644 index 0000000..05c5f06 --- /dev/null +++ b/plugins/nfc/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_NFC__=function(n,e){"use strict";const t=[84],r=[85];var o,c;function a(n,e,t,r){return{format:n,kind:"string"==typeof e?Array.from((new TextEncoder).encode(e)):e,id:"string"==typeof t?Array.from((new TextEncoder).encode(t)):t,payload:"string"==typeof r?Array.from((new TextEncoder).encode(r)):r}}n.TechKind=void 0,(o=n.TechKind||(n.TechKind={}))[o.IsoDep=0]="IsoDep",o[o.MifareClassic=1]="MifareClassic",o[o.MifareUltralight=2]="MifareUltralight",o[o.Ndef=3]="Ndef",o[o.NdefFormatable=4]="NdefFormatable",o[o.NfcA=5]="NfcA",o[o.NfcB=6]="NfcB",o[o.NfcBarcode=7]="NfcBarcode",o[o.NfcF=8]="NfcF",o[o.NfcV=9]="NfcV",n.NFCTypeNameFormat=void 0,(c=n.NFCTypeNameFormat||(n.NFCTypeNameFormat={}))[c.Empty=0]="Empty",c[c.NfcWellKnown=1]="NfcWellKnown",c[c.Media=2]="Media",c[c.AbsoluteURI=3]="AbsoluteURI",c[c.NfcExternal=4]="NfcExternal",c[c.Unknown=5]="Unknown",c[c.Unchanged=6]="Unchanged";const i=["","http://www.","https://www.","http://","https://","tel:","mailto:","ftp://anonymous:anonymous@","ftp://ftp.","ftps://","sftp://","smb://","nfs://","ftp://","dav://","news:","telnet://","imap:","rtsp://","urn:","pop:","sip:","sips:","tftp:","btspp://","btl2cap://","btgoep://","tcpobex://","irdaobex://","file://","urn:epc:id:","urn:epc:tag:","urn:epc:pat:","urn:epc:raw:","urn:epc:","urn:nfc:"];function f(n){const{type:e,...t}=n;return{[e]:t}}return n.RTD_TEXT=t,n.RTD_URI=r,n.isAvailable=async function(){const{available:n}=await e.invoke("plugin:nfc|is_available");return n},n.record=a,n.scan=async function(n,t){return await e.invoke("plugin:nfc|scan",{kind:f(n),...t})},n.textRecord=function(e,r,o="en"){const c=Array.from((new TextEncoder).encode(o+e));return c.unshift(o.length),a(n.NFCTypeNameFormat.NfcWellKnown,t,r??[],c)},n.uriRecord=function(e,t){return a(n.NFCTypeNameFormat.NfcWellKnown,r,t??[],function(n){let e="";i.slice(1).forEach(function(t){0!==e.length&&"urn:"!==e||0!==n.indexOf(t)||(e=t)}),0===e.length&&(e="");const t=Array.from((new TextEncoder).encode(n.slice(e.length))),r=i.indexOf(e);return t.unshift(r),t}(e))},n.write=async function(n,t){const{kind:r,...o}=t??{};r&&(o.kind=f(r)),await e.invoke("plugin:nfc|write",{records:n,...o})},n}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"nfc",{value:__TAURI_PLUGIN_NFC__})} diff --git a/plugins/nfc/build.rs b/plugins/nfc/build.rs new file mode 100644 index 0000000..bdcd84b --- /dev/null +++ b/plugins/nfc/build.rs @@ -0,0 +1,45 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["is_available", "write", "scan"]; + +fn main() { + let result = tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .try_build(); + + // when building documentation for Android the plugin build result is always Err() and is irrelevant to the crate documentation build + if !(cfg!(docsrs) && std::env::var("TARGET").unwrap().contains("android")) { + result.unwrap(); + } + + // TODO: triple check if this can reference the plugin's xml as it expects rn + // TODO: This has to be configurable if we want to support handling nfc tags when the app is not open. + tauri_plugin::mobile::update_android_manifest( + "NFC PLUGIN", + "activity", + r#" + + + + + + + + + + + + + + +"# + .to_string(), + ) + .expect("failed to rewrite AndroidManifest.xml"); +} diff --git a/plugins/nfc/contributors/crabnebula.svg b/plugins/nfc/contributors/crabnebula.svg new file mode 100644 index 0000000..a9bb460 --- /dev/null +++ b/plugins/nfc/contributors/crabnebula.svg @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/plugins/nfc/contributors/impierce.svg b/plugins/nfc/contributors/impierce.svg new file mode 100644 index 0000000..9d2a510 --- /dev/null +++ b/plugins/nfc/contributors/impierce.svg @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/plugins/nfc/guest-js/index.ts b/plugins/nfc/guest-js/index.ts new file mode 100644 index 0000000..35489b9 --- /dev/null +++ b/plugins/nfc/guest-js/index.ts @@ -0,0 +1,469 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Read and write NFC tags on Android and iOS. + * + * This plugin is mobile only, the APIs reject on desktop platforms. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * Record Type Definition (RTD) of an NDEF well known text record, the `"T"` (`0x54`) byte. + * + * @since 2.0.0 + */ +export const RTD_TEXT = [0x54] // "T" +/** + * Record Type Definition (RTD) of an NDEF well known URI record, the `"U"` (`0x55`) byte. + * + * @since 2.0.0 + */ +export const RTD_URI = [0x55] // "U" + +/** + * Filters the tags to scan by the URI of their payload. + * + * Every property is optional and only the ones that are set take part in the filter. + * **Android only**, the iOS implementation ignores this filter. + */ +export interface UriFilter { + /** Only match URIs with this scheme, e.g. `https`. */ + scheme?: string + /** Only match URIs with this authority (host), e.g. `tauri.app`. */ + host?: string + /** Only match URIs whose path starts with this prefix, e.g. `/docs`. */ + pathPrefix?: string +} + +/** + * The NFC technologies a tag can support, mirroring the `android.nfc.tech` classes. + * + * **Android only**, used by the `techLists` filter of an `ndef` {@link ScanKind}. + */ +export enum TechKind { + /** ISO-DEP (ISO 14443-4) properties and I/O operations. */ + IsoDep, + /** MIFARE Classic properties and I/O operations. */ + MifareClassic, + /** MIFARE Ultralight and MIFARE Ultralight C properties and I/O operations. */ + MifareUltralight, + /** NDEF data and operations on tags that are already formatted as NDEF. */ + Ndef, + /** Formatting operations on tags that can be formatted as NDEF but are not yet. */ + NdefFormatable, + /** NFC-A (ISO 14443-3A) properties and I/O operations. */ + NfcA, + /** NFC-B (ISO 14443-3B) properties and I/O operations. */ + NfcB, + /** NFC Barcode (Kovio NFC Barcode) properties and I/O operations. */ + NfcBarcode, + /** NFC-F (JIS 6319-4) properties and I/O operations. */ + NfcF, + /** NFC-V (ISO 15693) properties and I/O operations. */ + NfcV +} + +/** + * The kind of scan to perform, which defines which tags are matched. + * + * Use `tag` to match any discovered tag and `ndef` to only match tags carrying an NDEF message. + */ +export type ScanKind = + | { + /** Matches any tag that is discovered, whether it carries an NDEF message or not. */ + type: 'tag' + /** Only match tags whose payload URI matches this filter. **Android only**. */ + uri?: UriFilter + /** Only match tags whose payload has this MIME type, e.g. `text/plain`. **Android only**. */ + mimeType?: string + } + | { + /** Only matches tags that carry an NDEF message. */ + type: 'ndef' + /** Only match tags whose NDEF payload URI matches this filter. **Android only**. */ + uri?: UriFilter + /** + * Only match tags whose NDEF payload has this MIME type, e.g. `text/plain`. + * **Android only**. + */ + mimeType?: string + /** + * Each of the tech-lists is considered independently and the activity is considered a match if + * any single tech-list matches the tag that was discovered. + * This provides AND and OR semantics for filtering desired techs. + * + * See for more information. + * + * Examples + * + * ```ts + * import type { TechKind } from "@tauri-apps/plugin-nfc" + * + * const techLists = [ + * // capture anything using NfcF + * [TechKind.NfcF], + * // capture all MIFARE Classics with NDEF payloads + * [TechKind.NfcA, TechKind.MifareClassic, TechKind.Ndef] + * ] + * ``` + */ + techLists?: TechKind[][] + } + +/** Options for the {@link scan} function. */ +export interface ScanOptions { + /** + * Whether the connection to the scanned tag must be kept open after the scan resolves, + * so that a following {@link write} call writes to that tag instead of scanning a new one. + * Defaults to `false`. + */ + keepSessionAlive?: boolean + /** Message displayed in the UI. iOS only. */ + message?: string + /** Message displayed in the UI when the message has been read. iOS only. */ + successMessage?: string +} + +/** Options for the {@link write} function. */ +export interface WriteOptions { + /** + * The kind of scan to perform to find the tag to write to. + * Required on Android unless a {@link scan} session is kept alive. + */ + kind?: ScanKind + /** Message displayed in the UI when reading the tag. iOS only. */ + message?: string + /** Message displayed in the UI when the tag has been read. iOS only. */ + successfulReadMessage?: string + /** Message displayed in the UI when the message has been written. iOS only. */ + successMessage?: string +} + +/** + * The Type Name Format (TNF) of an NDEF record, + * which defines how the record type is interpreted. + */ +export enum NFCTypeNameFormat { + /** The record is empty: type, identifier and payload must be empty. */ + Empty = 0, + /** + * The record type is an NFC Forum well known type, defined by a Record Type Definition (RTD) + * such as {@link RTD_TEXT} or {@link RTD_URI}. + */ + NfcWellKnown = 1, + /** The record type is a MIME media type as defined in RFC 2046, e.g. `text/plain`. */ + Media = 2, + /** The record type is an absolute URI as defined in RFC 3986. */ + AbsoluteURI = 3, + /** The record type is an NFC Forum external type, i.e. a type namespaced by its issuer. */ + NfcExternal = 4, + /** + * The record type is unknown: the type must be empty and the payload interpretation + * is left to the application. + */ + Unknown = 5, + /** + * The record is a middle or last chunk of a chunked record and inherits the type of the + * first chunk, so its own type must be empty. + */ + Unchanged = 6 +} + +/** An NDEF record read from a scanned tag. */ +export interface TagRecord { + /** + * The Type Name Format (TNF) of the record, + * which defines how {@link TagRecord.kind} is interpreted. + */ + tnf: NFCTypeNameFormat + /** The record type bytes. */ + kind: number[] + /** The record identifier bytes. Can be empty. */ + id: number[] + /** The record payload bytes. */ + payload: number[] +} + +/** An NFC tag that has been scanned. */ +export interface Tag { + /** The tag identifier bytes, as reported by the operating system. */ + id: number[] + /** The technologies the tag supports, e.g. `["android.nfc.tech.Ndef"]` on Android. */ + kind: string[] + /** The NDEF records stored on the tag. Empty when the tag holds no NDEF message. */ + records: TagRecord[] +} + +/** + * An NDEF record to be written to a tag. + * + * Use {@link record}, {@link textRecord} or {@link uriRecord} to create one. + */ +export interface NFCRecord { + /** The Type Name Format (TNF) of the record. */ + format: NFCTypeNameFormat + /** + * The record type, interpreted according to {@link NFCRecord.format}. + * For well known records this is a Record Type Definition (RTD) value + * such as {@link RTD_TEXT} or {@link RTD_URI}. + */ + kind: number[] + /** The record identifier. Can be empty. */ + id: number[] + /** The record payload bytes. */ + payload: number[] +} + +/** + * Creates an NDEF record with the given type name format, type, identifier and payload. + * + * Strings are encoded as UTF-8 byte arrays. + * + * @example + * ```typescript + * import { record, NFCTypeNameFormat, write } from '@tauri-apps/plugin-nfc'; + * const mimeRecord = record(NFCTypeNameFormat.Media, 'text/plain', '', 'hello world'); + * await write([mimeRecord], { kind: { type: 'ndef' } }); + * ``` + * + * @param format The Type Name Format (TNF) of the record. + * @param kind The record type, interpreted according to `format`. + * @param id The record identifier. Use an empty string or array when the record has none. + * @param payload The record payload. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ +export function record( + format: NFCTypeNameFormat, + kind: string | number[], + id: string | number[], + payload: string | number[] +): NFCRecord { + return { + format, + kind: + typeof kind === 'string' + ? Array.from(new TextEncoder().encode(kind)) + : kind, + id: typeof id === 'string' ? Array.from(new TextEncoder().encode(id)) : id, + payload: + typeof payload === 'string' + ? Array.from(new TextEncoder().encode(payload)) + : payload + } +} + +/** + * Creates an NDEF well known text record ({@link RTD_TEXT}). + * + * The payload is the UTF-8 encoded text prefixed by the language code + * and by a status byte holding the length of that language code. + * + * @example + * ```typescript + * import { textRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([textRecord('hello world')], { kind: { type: 'ndef' } }); + * ``` + * + * @param text The text to store in the record. + * @param id The record identifier. Defaults to an empty identifier. + * @param language The IANA language code of the text. Defaults to `en`. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ +export function textRecord( + text: string, + id?: string | number[], + language: string = 'en' +): NFCRecord { + const payload = Array.from(new TextEncoder().encode(language + text)) + payload.unshift(language.length) + return record(NFCTypeNameFormat.NfcWellKnown, RTD_TEXT, id ?? [], payload) +} + +const protocols = [ + '', + 'http://www.', + 'https://www.', + 'http://', + 'https://', + 'tel:', + 'mailto:', + 'ftp://anonymous:anonymous@', + 'ftp://ftp.', + 'ftps://', + 'sftp://', + 'smb://', + 'nfs://', + 'ftp://', + 'dav://', + 'news:', + 'telnet://', + 'imap:', + 'rtsp://', + 'urn:', + 'pop:', + 'sip:', + 'sips:', + 'tftp:', + 'btspp://', + 'btl2cap://', + 'btgoep://', + 'tcpobex://', + 'irdaobex://', + 'file://', + 'urn:epc:id:', + 'urn:epc:tag:', + 'urn:epc:pat:', + 'urn:epc:raw:', + 'urn:epc:', + 'urn:nfc:' +] + +function encodeURI(uri: string): number[] { + let prefix = '' + + protocols.slice(1).forEach(function (protocol) { + if ( + (prefix.length === 0 || prefix === 'urn:') + && uri.indexOf(protocol) === 0 + ) { + prefix = protocol + } + }) + + if (prefix.length === 0) { + prefix = '' + } + + const encoded = Array.from(new TextEncoder().encode(uri.slice(prefix.length))) + const protocolCode = protocols.indexOf(prefix) + // prepend protocol code + encoded.unshift(protocolCode) + + return encoded +} + +/** + * Creates an NDEF well known URI record ({@link RTD_URI}). + * + * The URI is encoded with the NDEF URI abbreviation scheme: a known prefix such as + * `https://` is replaced by the identifier code byte that starts the payload. + * + * @example + * ```typescript + * import { uriRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([uriRecord('https://tauri.app')], { kind: { type: 'ndef' } }); + * ``` + * + * @param uri The URI to store in the record. + * @param id The record identifier. Defaults to an empty identifier. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ +export function uriRecord(uri: string, id?: string | number[]): NFCRecord { + return record( + NFCTypeNameFormat.NfcWellKnown, + RTD_URI, + id ?? [], + encodeURI(uri) + ) +} + +function mapScanKind(kind: ScanKind): Record { + const { type: scanKind, ...kindOptions } = kind + return { [scanKind]: kindOptions } +} + +/** + * Scans an NFC tag, resolving when a tag matching the given filters is read. + * + * See for more information. + * + * @example + * ```typescript + * import { scan } from '@tauri-apps/plugin-nfc'; + * const tag = await scan({ type: 'tag' }); + * ``` + * + * @param kind The kind of scan to perform, which defines how tags are matched. + * @param options Additional scan options such as the iOS UI messages and whether the + * session must be kept alive for a following {@link write} call. + * @returns A promise resolving to the tag that has been scanned. + * + * @since 2.0.0 + */ +export async function scan( + kind: ScanKind, + options?: ScanOptions +): Promise { + return await invoke('plugin:nfc|scan', { + kind: mapScanKind(kind), + ...options + }) +} + +/** + * Write to an NFC tag. + * + * If you did not previously call {@link scan} with {@link ScanOptions.keepSessionAlive} set to true, + * it will first scan the tag then write to it. + * + * @example + * ```typescript + * import { uriRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([uriRecord('https://tauri.app')], { kind: { type: 'ndef' } }); + * ``` + * + * @param records The NDEF records to write to the tag. + * @param options Additional write options such as the kind of scan used to find the tag + * and the iOS UI messages. + * + * @since 2.0.0 + */ +export async function write( + records: NFCRecord[], + options?: WriteOptions +): Promise { + const { kind, ...opts } = options ?? {} + if (kind) { + // @ts-expect-error map the property + opts.kind = mapScanKind(kind) + } + await invoke('plugin:nfc|write', { + records, + ...opts + }) +} + +/** + * Checks whether NFC is supported by the device and currently usable by the app. + * + * Resolves to `false` on Android when the device has no NFC adapter or NFC is disabled in the + * device settings, and on iOS when the `NFCReaderUsageDescription` entry is missing from the + * `Info.plist` file or NFC tag reading is unavailable. + * + * @example + * ```typescript + * import { isAvailable } from '@tauri-apps/plugin-nfc'; + * const canScan = await isAvailable(); + * ``` + * + * @returns A promise resolving to whether NFC is available on the device. + * + * @since 2.0.0 + */ +export async function isAvailable(): Promise { + const { available }: { available: boolean } = await invoke( + 'plugin:nfc|is_available' + ) + return available +} diff --git a/plugins/nfc/ios/.gitignore b/plugins/nfc/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/nfc/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/nfc/ios/Package.swift b/plugins/nfc/ios/Package.swift new file mode 100644 index 0000000..a028db7 --- /dev/null +++ b/plugins/nfc/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-nfc", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-nfc", + type: .static, + targets: ["tauri-plugin-nfc"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-nfc", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/nfc/ios/README.md b/plugins/nfc/ios/README.md new file mode 100644 index 0000000..88a429b --- /dev/null +++ b/plugins/nfc/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin Nfc + +A description of this package. diff --git a/plugins/nfc/ios/Sources/NfcPlugin.swift b/plugins/nfc/ios/Sources/NfcPlugin.swift new file mode 100644 index 0000000..af1a68d --- /dev/null +++ b/plugins/nfc/ios/Sources/NfcPlugin.swift @@ -0,0 +1,523 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// https://developer.apple.com/documentation/corenfc/building_an_nfc_tag-reader_app + +import CoreNFC +import SwiftRs +import Tauri +import UIKit +import WebKit + +enum ScanKind: Decodable { + case ndef, tag +} + +struct ScanOptions: Decodable { + let kind: ScanKind + var keepSessionAlive: Bool? + var message: String? + var successMessage: String? +} + +struct NDEFRecord: Decodable { + var format: UInt8? + var kind: [UInt8]? + var identifier: [UInt8]? + var payload: [UInt8]? +} + +struct WriteOptions: Decodable { + var kind: ScanKind? + let records: [NDEFRecord] + var message: String? + var successMessage: String? + var successfulReadMessage: String? +} + +enum TagProcessMode { + case write(message: NFCNDEFMessage) + case read +} + +class Session { + let nfcSession: NFCReaderSession? + let invoke: Invoke + var keepAlive: Bool + let tagProcessMode: TagProcessMode + var tagStatus: NFCNDEFStatus? + var tag: NFCNDEFTag? + let successfulReadMessage: String? + let successfulWriteAlertMessage: String? + + init( + nfcSession: NFCReaderSession?, + invoke: Invoke, + keepAlive: Bool, + tagProcessMode: TagProcessMode, + successfulReadMessage: String?, + successfulWriteAlertMessage: String? + ) { + self.nfcSession = nfcSession + self.invoke = invoke + self.keepAlive = keepAlive + self.tagProcessMode = tagProcessMode + self.successfulReadMessage = successfulReadMessage + self.successfulWriteAlertMessage = successfulWriteAlertMessage + } +} + +class NfcStatus { + let available: Bool + let errorReason: String? + + init(available: Bool, errorReason: String?) { + self.available = available + self.errorReason = errorReason + } +} + +class NfcPlugin: Plugin, NFCTagReaderSessionDelegate, NFCNDEFReaderSessionDelegate { + var session: Session? + var status: NfcStatus! + + public override func load(webview: WKWebView) { + var available = false + var errorReason: String? + + let entry = Bundle.main.infoDictionary?["NFCReaderUsageDescription"] as? String + + if entry == nil || entry?.count == 0 { + errorReason = "missing NFCReaderUsageDescription configuration on the Info.plist file" + } else if !NFCNDEFReaderSession.readingAvailable { + errorReason = + "NFC tag reading unavailable, make sure the Near-Field Communication capability on Xcode is enabled and the device supports NFC tag reading" + } else { + available = true + } + + if let error = errorReason { + Logger.error("\(error)") + } + + self.status = NfcStatus(available: available, errorReason: errorReason) + } + + func tagReaderSessionDidBecomeActive( + _ session: NFCTagReaderSession + ) { + Logger.info("tagReaderSessionDidBecomeActive") + } + + func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + let tag = tags.first! + + session.connect( + to: tag, + completionHandler: { [self] (error) in + if let error = error { + self.closeSession(session, error: "cannot connect to tag: \(error)") + + } else { + let ndefTag: NFCNDEFTag + switch tag { + case let .feliCa(tag): + ndefTag = tag as NFCNDEFTag + break + case let .miFare(tag): + ndefTag = tag as NFCNDEFTag + break + case let .iso15693(tag): + ndefTag = tag as NFCNDEFTag + break + case let .iso7816(tag): + ndefTag = tag as NFCNDEFTag + break + default: + return + } + + self.processTag( + session: session, tag: ndefTag, metadata: tagMetadata(tag), + mode: self.session!.tagProcessMode) + } + } + ) + } + + func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { + Logger.error("Tag reader session error \(error)") + self.session?.invoke.reject("session invalidated with error: \(error)") + self.session = nil + } + + func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { + let message = messages.first! + // TODO: do we really need this hook? + self.session?.invoke.resolve(["records": ndefMessageRecords(message)]) + } + + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { + let tag = tags.first! + + session.connect( + to: tag, + completionHandler: { [self] (error) in + if let error = error { + self.closeSession(session, error: "cannot connect to tag: \(error)") + + } else { + var metadata: JsonObject = [:] + if tag.isKind(of: NFCFeliCaTag.self) { + metadata["kind"] = ["FeliCa"] + metadata["id"] = nil + } else if let t = tag as? NFCMiFareTag { + metadata["kind"] = ["MiFare"] + metadata["id"] = byteArrayFromData(t.identifier) + } else if let t = tag as? NFCISO15693Tag { + metadata["kind"] = ["ISO15693"] + metadata["id"] = byteArrayFromData(t.identifier) + } else if let t = tag as? NFCISO7816Tag { + metadata["kind"] = ["ISO7816Compatible"] + metadata["id"] = byteArrayFromData(t.identifier) + } + + self.processTag( + session: session, tag: tag, metadata: metadata, + mode: self.session!.tagProcessMode) + } + } + ) + + } + + func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { + if (error as NSError).code + == NFCReaderError.Code.readerSessionInvalidationErrorFirstNDEFTagRead.rawValue + { + // not an error because we're using invalidateAfterFirstRead: true + Logger.debug("readerSessionInvalidationErrorFirstNDEFTagRead") + } else { + Logger.error("NDEF reader session error \(error)") + self.session?.invoke.reject("session invalidated with error: \(error)") + self.session = nil + } + } + + private func tagMetadata(_ tag: NFCTag) -> JsonObject { + var metadata: JsonObject = [:] + + switch tag { + case .feliCa: + metadata["kind"] = ["FeliCa"] + metadata["id"] = [] + break + case let .miFare(tag): + metadata["kind"] = ["MiFare"] + metadata["id"] = byteArrayFromData(tag.identifier) + break + case let .iso15693(tag): + metadata["kind"] = ["ISO15693"] + metadata["id"] = byteArrayFromData(tag.identifier) + break + case let .iso7816(tag): + metadata["kind"] = ["ISO7816Compatible"] + metadata["id"] = byteArrayFromData(tag.identifier) + break + default: + metadata["kind"] = ["Unknown"] + metadata["id"] = [] + break + } + + return metadata + } + + private func closeSession(_ session: NFCReaderSession) { + session.invalidate() + self.session = nil + } + + private func closeSession(_ session: NFCReaderSession, error: String) { + session.invalidate(errorMessage: error) + self.session = nil + } + + private func processTag( + session: NFCReaderSession, tag: T, metadata: JsonObject, mode: TagProcessMode + ) { + tag.queryNDEFStatus(completionHandler: { + [self] (status, capacity, error) in + if let error = error { + self.closeSession(session, error: "cannot connect to tag: \(error)") + } else { + switch mode { + case .write(let message): + self.writeNDEFTag( + session: session, status: status, tag: tag, message: message, + alertMessage: self.session?.successfulWriteAlertMessage) + break + case .read: + if self.session?.keepAlive == true { + self.session!.tagStatus = status + self.session!.tag = tag + } + self.readNDEFTag( + session: session, status: status, tag: tag, metadata: metadata, + alertMessage: self.session?.successfulReadMessage) + break + } + } + }) + } + + private func writeNDEFTag( + session: NFCReaderSession, status: NFCNDEFStatus, tag: T, message: NFCNDEFMessage, + alertMessage: String? + ) { + switch status { + case .notSupported: + self.closeSession(session, error: "Tag is not an NDEF-formatted tag") + break + case .readOnly: + self.closeSession(session, error: "Read only tag") + break + case .readWrite: + if let currentSession = self.session { + tag.writeNDEF( + message, + completionHandler: { (error) in + if let error = error { + self.closeSession(session, error: "cannot write to tag: \(error)") + } else { + if let message = alertMessage { + session.alertMessage = message + } + currentSession.invoke.resolve() + + self.closeSession(session) + + } + }) + } + break + default: + return + } + } + + private func readNDEFTag( + session: NFCReaderSession, status: NFCNDEFStatus, tag: T, metadata m: JsonObject, + alertMessage: String? + ) { + var metadata: JsonObject = [:] + metadata.merge(m) { (_, new) in new } + + switch status { + case .notSupported: + self.resolveInvoke(message: nil, metadata: metadata) + self.closeSession(session) + return + case .readOnly: + metadata["readOnly"] = true + break + case .readWrite: + metadata["readOnly"] = false + break + default: + break + } + + tag.readNDEF(completionHandler: { + [self] (message, error) in + if let error = error { + let code = (error as NSError).code + if code != 403 { + self.closeSession(session, error: "Failed to read: \(error)") + return + } + } + + if let message = alertMessage { + session.alertMessage = message + } + self.resolveInvoke(message: message, metadata: metadata) + + if self.session?.keepAlive != true { + self.closeSession(session) + } + }) + } + + private func resolveInvoke(message: NFCNDEFMessage?, metadata: JsonObject) { + var data: JsonObject = [:] + + data.merge(metadata) { (_, new) in new } + + if let message = message { + data["records"] = ndefMessageRecords(message) + } else { + data["records"] = [] + } + + self.session?.invoke.resolve(data) + } + + private func ndefMessageRecords(_ message: NFCNDEFMessage) -> [JsonObject] { + var records: [JsonObject] = [] + for record in message.records { + var recordJson: JsonObject = [:] + recordJson["tnf"] = record.typeNameFormat.rawValue + recordJson["kind"] = byteArrayFromData(record.type) + recordJson["id"] = byteArrayFromData(record.identifier) + recordJson["payload"] = byteArrayFromData(record.payload) + + records.append(recordJson) + } + + return records + } + + private func byteArrayFromData(_ data: Data) -> [UInt8] { + var arr: [UInt8] = [] + for b in data { + arr.append(b) + } + return arr + } + + private func dataFromByteArray(_ array: [UInt8]) -> Data { + var data = Data(capacity: array.count) + + data.append(contentsOf: array) + + return data + } + + @objc func isAvailable(_ invoke: Invoke) { + invoke.resolve([ + "available": self.status.available + ]) + } + + @objc public func write(_ invoke: Invoke) throws { + if !self.status.available { + invoke.reject("NFC reading unavailable: \(self.status.errorReason ?? "")") + return + } + + let args = try invoke.parseArgs(WriteOptions.self) + + var ndefPayloads = [NFCNDEFPayload]() + + for record in args.records { + ndefPayloads.append( + NFCNDEFPayload( + format: NFCTypeNameFormat(rawValue: record.format ?? 0) ?? .unknown, + type: dataFromByteArray(record.kind ?? []), + identifier: dataFromByteArray(record.identifier ?? []), + payload: dataFromByteArray(record.payload ?? []) + ) + ) + } + + if let session = self.session { + if let nfcSession = session.nfcSession, let tagStatus = session.tagStatus, + let tag = session.tag + { + session.keepAlive = false + self.writeNDEFTag( + session: nfcSession, status: tagStatus, tag: tag, + message: NFCNDEFMessage(records: ndefPayloads), + alertMessage: args.successMessage + ) + } else { + invoke.reject( + "connected tag not found, please wait for it to be available and then call write()") + } + } else { + self.startScanSession( + invoke: invoke, + kind: args.kind ?? .ndef, + keepAlive: true, + invalidateAfterFirstRead: false, + tagProcessMode: .write( + message: NFCNDEFMessage(records: ndefPayloads) + ), + alertMessage: args.message, + successfulReadMessage: args.successfulReadMessage, + successfulWriteAlertMessage: args.successMessage + ) + } + } + + @objc public func scan(_ invoke: Invoke) throws { + if !self.status.available { + invoke.reject("NFC reading unavailable: \(self.status.errorReason ?? "")") + return + } + + let args = try invoke.parseArgs(ScanOptions.self) + + self.startScanSession( + invoke: invoke, + kind: args.kind, + keepAlive: args.keepSessionAlive ?? false, + invalidateAfterFirstRead: true, + tagProcessMode: .read, + alertMessage: args.message, + successfulReadMessage: args.successMessage, + successfulWriteAlertMessage: nil + ) + } + + private func startScanSession( + invoke: Invoke, + kind: ScanKind, + keepAlive: Bool, + invalidateAfterFirstRead: Bool, + tagProcessMode: TagProcessMode, + alertMessage: String?, + successfulReadMessage: String?, + successfulWriteAlertMessage: String? + ) { + let nfcSession: NFCReaderSession? + + switch kind { + case .tag: + nfcSession = NFCTagReaderSession( + pollingOption: [.iso14443, .iso15693], + delegate: self, + queue: DispatchQueue.main + ) + break + case .ndef: + nfcSession = NFCNDEFReaderSession( + delegate: self, + queue: DispatchQueue.main, + invalidateAfterFirstRead: invalidateAfterFirstRead + ) + break + } + + if let message = alertMessage { + nfcSession?.alertMessage = message + } + nfcSession?.begin() + + self.session = Session( + nfcSession: nfcSession, + invoke: invoke, + keepAlive: keepAlive, + tagProcessMode: tagProcessMode, + successfulReadMessage: successfulReadMessage, + successfulWriteAlertMessage: successfulWriteAlertMessage + ) + } +} + +@_cdecl("init_plugin_nfc") +func initPlugin() -> Plugin { + return NfcPlugin() +} diff --git a/plugins/nfc/ios/Tests/PluginTests/PluginTests.swift b/plugins/nfc/ios/Tests/PluginTests/PluginTests.swift new file mode 100644 index 0000000..99992ce --- /dev/null +++ b/plugins/nfc/ios/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import XCTest +@testable import ExamplePlugin + +final class ExamplePluginTests: XCTestCase { + func testExample() throws { + let plugin = ExamplePlugin() + } +} diff --git a/plugins/nfc/package.json b/plugins/nfc/package.json new file mode 100644 index 0000000..366bf1c --- /dev/null +++ b/plugins/nfc/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-nfc", + "version": "2.3.6", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "!dist-js/**/*.map", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/nfc/permissions/autogenerated/commands/is_available.toml b/plugins/nfc/permissions/autogenerated/commands/is_available.toml new file mode 100644 index 0000000..6f49c5a --- /dev/null +++ b/plugins/nfc/permissions/autogenerated/commands/is_available.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-available" +description = "Enables the is_available command without any pre-configured scope." +commands.allow = ["is_available"] + +[[permission]] +identifier = "deny-is-available" +description = "Denies the is_available command without any pre-configured scope." +commands.deny = ["is_available"] diff --git a/plugins/nfc/permissions/autogenerated/commands/scan.toml b/plugins/nfc/permissions/autogenerated/commands/scan.toml new file mode 100644 index 0000000..efa621d --- /dev/null +++ b/plugins/nfc/permissions/autogenerated/commands/scan.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-scan" +description = "Enables the scan command without any pre-configured scope." +commands.allow = ["scan"] + +[[permission]] +identifier = "deny-scan" +description = "Denies the scan command without any pre-configured scope." +commands.deny = ["scan"] diff --git a/plugins/nfc/permissions/autogenerated/commands/write.toml b/plugins/nfc/permissions/autogenerated/commands/write.toml new file mode 100644 index 0000000..73d1d38 --- /dev/null +++ b/plugins/nfc/permissions/autogenerated/commands/write.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-write" +description = "Enables the write command without any pre-configured scope." +commands.allow = ["write"] + +[[permission]] +identifier = "deny-write" +description = "Denies the write command without any pre-configured scope." +commands.deny = ["write"] diff --git a/plugins/nfc/permissions/autogenerated/reference.md b/plugins/nfc/permissions/autogenerated/reference.md new file mode 100644 index 0000000..0e408eb --- /dev/null +++ b/plugins/nfc/permissions/autogenerated/reference.md @@ -0,0 +1,103 @@ +## Default Permission + +This permission set configures what kind of +operations are available from the nfc plugin. + +#### Granted Permissions + +Checking if the NFC functionality is available +and scanning nearby tags is allowed. +Writing to tags needs to be manually enabled. + +#### This default permission set includes the following: + +- `allow-is-available` +- `allow-scan` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`nfc:allow-is-available` + + + +Enables the is_available command without any pre-configured scope. + +
+ +`nfc:deny-is-available` + + + +Denies the is_available command without any pre-configured scope. + +
+ +`nfc:allow-scan` + + + +Enables the scan command without any pre-configured scope. + +
+ +`nfc:deny-scan` + + + +Denies the scan command without any pre-configured scope. + +
+ +`nfc:allow-write` + + + +Enables the write command without any pre-configured scope. + +
+ +`nfc:deny-write` + + + +Denies the write command without any pre-configured scope. + +
diff --git a/plugins/nfc/permissions/default.toml b/plugins/nfc/permissions/default.toml new file mode 100644 index 0000000..d69c7f1 --- /dev/null +++ b/plugins/nfc/permissions/default.toml @@ -0,0 +1,15 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures what kind of +operations are available from the nfc plugin. + +#### Granted Permissions + +Checking if the NFC functionality is available +and scanning nearby tags is allowed. +Writing to tags needs to be manually enabled. + +""" +permissions = ["allow-is-available", "allow-scan"] diff --git a/plugins/nfc/permissions/schemas/schema.json b/plugins/nfc/permissions/schemas/schema.json new file mode 100644 index 0000000..8a018e2 --- /dev/null +++ b/plugins/nfc/permissions/schemas/schema.json @@ -0,0 +1,342 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the is_available command without any pre-configured scope.", + "type": "string", + "const": "allow-is-available", + "markdownDescription": "Enables the is_available command without any pre-configured scope." + }, + { + "description": "Denies the is_available command without any pre-configured scope.", + "type": "string", + "const": "deny-is-available", + "markdownDescription": "Denies the is_available command without any pre-configured scope." + }, + { + "description": "Enables the scan command without any pre-configured scope.", + "type": "string", + "const": "allow-scan", + "markdownDescription": "Enables the scan command without any pre-configured scope." + }, + { + "description": "Denies the scan command without any pre-configured scope.", + "type": "string", + "const": "deny-scan", + "markdownDescription": "Denies the scan command without any pre-configured scope." + }, + { + "description": "Enables the write command without any pre-configured scope.", + "type": "string", + "const": "allow-write", + "markdownDescription": "Enables the write command without any pre-configured scope." + }, + { + "description": "Denies the write command without any pre-configured scope.", + "type": "string", + "const": "deny-write", + "markdownDescription": "Denies the write command without any pre-configured scope." + }, + { + "description": "This permission set configures what kind of\noperations are available from the nfc plugin.\n\n#### Granted Permissions\n\nChecking if the NFC functionality is available\nand scanning nearby tags is allowed.\nWriting to tags needs to be manually enabled.\n\n\n#### This default permission set includes:\n\n- `allow-is-available`\n- `allow-scan`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures what kind of\noperations are available from the nfc plugin.\n\n#### Granted Permissions\n\nChecking if the NFC functionality is available\nand scanning nearby tags is allowed.\nWriting to tags needs to be manually enabled.\n\n\n#### This default permission set includes:\n\n- `allow-is-available`\n- `allow-scan`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/nfc/rollup.config.js b/plugins/nfc/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/nfc/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/nfc/src/error.rs b/plugins/nfc/src/error.rs new file mode 100644 index 0000000..75d21ea --- /dev/null +++ b/plugins/nfc/src/error.rs @@ -0,0 +1,34 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for a [`std::result::Result`] with the error type [`Error`]. +pub type Result = std::result::Result; + +/// Errors returned by the NFC plugin. +/// +/// Serializes to the error message string, so it can be returned directly from a command. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An I/O error happened. + #[error(transparent)] + Io(#[from] std::io::Error), + /// The call to the Android or iOS plugin implementation failed, + /// either because the arguments could not be serialized, the response could not be + /// deserialized or the native side rejected the call (e.g. NFC is unavailable + /// or the tag could not be read or written). + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/nfc/src/lib.rs b/plugins/nfc/src/lib.rs new file mode 100644 index 0000000..0396b77 --- /dev/null +++ b/plugins/nfc/src/lib.rs @@ -0,0 +1,122 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Read and write NFC tags on Android and iOS. +//! +//! This plugin is mobile only: the whole crate is gated behind `#[cfg(mobile)]`, +//! so it expands to nothing when compiling for Linux, macOS or Windows. +//! +//! Register the plugin with `init` and use the `NfcExt` trait to reach the +//! `Nfc` instance from any `tauri::Manager` implementation (the app handle, a window, ...). + +#![cfg(mobile)] + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, PluginHandle, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.nfc"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_nfc); + +/// Access to the nfc APIs. +pub struct Nfc(PluginHandle); + +#[derive(Deserialize)] +struct IsAvailableResponse { + available: bool, +} + +#[derive(Serialize)] +struct WriteRequest { + records: Vec, +} + +impl Nfc { + /// Checks whether NFC is supported by the device and currently usable by the app. + /// + /// On Android this is `false` when the device has no NFC adapter or when NFC is + /// disabled in the device settings. + /// On iOS this is `false` when the `NFCReaderUsageDescription` entry is missing from the + /// `Info.plist` file or when NFC tag reading is not available on the device. + pub fn is_available(&self) -> crate::Result { + self.0 + .run_mobile_plugin::("isAvailable", ()) + .map(|r| r.available) + .map_err(Into::into) + } + + /// Scans an NFC tag, blocking until a tag matching the given [`ScanRequest::kind`] filters + /// is read or the scan fails. + /// + /// Set [`ScanRequest::keep_session_alive`] to `true` to keep the connection to the tag open + /// after it has been scanned, so that a following [`Self::write`] call writes to that tag. + /// + /// # Errors + /// + /// Returns an error when NFC is not available (see [`Self::is_available`]) + /// or when the tag could not be read. + pub fn scan(&self, payload: ScanRequest) -> crate::Result { + self.0 + .run_mobile_plugin("scan", payload) + .map(|v| ScanResponse { tag: v }) + .map_err(Into::into) + } + + /// Writes the given NDEF records to an NFC tag, blocking until the write completes or fails. + /// + /// Because this API does not take a scan kind, on Android it can only write to the tag of an + /// ongoing session, so it must be preceded by a [`Self::scan`] call with + /// [`ScanRequest::keep_session_alive`] set to `true`. + /// On iOS an NDEF reader session is started when there is no ongoing session, and the + /// records are written to the first tag that is scanned. + /// + /// # Errors + /// + /// Returns an error when NFC is not available (see [`Self::is_available`]), when there is no + /// connected tag on Android, when the tag is read-only, when it cannot hold the message or + /// when it does not support the NDEF format. + pub fn write(&self, records: Vec) -> crate::Result<()> { + self.0 + .run_mobile_plugin("write", WriteRequest { records }) + .map_err(Into::into) + } +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the NFC APIs. +pub trait NfcExt { + /// Returns the [`Nfc`] instance managed by the plugin. + fn nfc(&self) -> &Nfc; +} + +impl> crate::NfcExt for T { + fn nfc(&self) -> &Nfc { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("nfc") + .setup(|app, api| { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "NfcPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_nfc)?; + app.manage(Nfc(handle)); + Ok(()) + }) + .build() +} diff --git a/plugins/nfc/src/mobile.rs b/plugins/nfc/src/mobile.rs new file mode 100644 index 0000000..cf34d45 --- /dev/null +++ b/plugins/nfc/src/mobile.rs @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; diff --git a/plugins/nfc/src/models.rs b/plugins/nfc/src/models.rs new file mode 100644 index 0000000..ccb4c55 --- /dev/null +++ b/plugins/nfc/src/models.rs @@ -0,0 +1,195 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize, Serializer}; +use std::fmt::Display; + +/// Arguments of the [`Nfc::scan`](crate::Nfc::scan) API. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanRequest { + /// The kind of scan to perform, which defines how tags are matched. + pub kind: ScanKind, + /// Whether the connection to the scanned tag must be kept open after the scan resolves. + /// + /// When `true`, a following [`Nfc::write`](crate::Nfc::write) call writes to the tag that was + /// scanned instead of starting a new session. + pub keep_session_alive: bool, +} + +/// An NDEF record to be written to a tag. +/// +/// Use [`NFCTypeNameFormat`] to describe how [`Self::kind`] must be interpreted. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NfcRecord { + /// The Type Name Format (TNF) of the record. + pub format: NFCTypeNameFormat, + /// The record type, interpreted according to [`Self::format`]. + /// + /// For [`NFCTypeNameFormat::NfcWellKnown`] records this is a Record Type Definition (RTD) + /// value such as `[0x54]` (`RTD_TEXT`) or `[0x55]` (`RTD_URI`). + pub kind: Vec, + /// The record identifier. Can be empty. + pub id: Vec, + /// The record payload bytes. + pub payload: Vec, +} + +/// The Type Name Format (TNF) of an NDEF record, which defines how the record type is interpreted. +/// +/// Serialized as its numeric value. +#[derive(serde_repr::Deserialize_repr, serde_repr::Serialize_repr)] +#[repr(u8)] +pub enum NFCTypeNameFormat { + /// The record is empty: type, identifier and payload must be empty. + Empty = 0, + /// The record type is an NFC Forum well known type, defined by a Record Type Definition (RTD) + /// such as `RTD_TEXT` (`[0x54]`) or `RTD_URI` (`[0x55]`). + NfcWellKnown = 1, + /// The record type is a MIME media type as defined in RFC 2046, e.g. `text/plain`. + Media = 2, + /// The record type is an absolute URI as defined in RFC 3986. + AbsoluteURI = 3, + /// The record type is an NFC Forum external type, i.e. a type namespaced by its issuer. + NfcExternal = 4, + /// The record type is unknown: the type must be empty and the payload interpretation is + /// left to the application. + Unknown = 5, + /// The record is a middle or last chunk of a chunked record and inherits the type of the + /// first chunk, so its own type must be empty. + Unchanged = 6, +} + +/// An NDEF record read from a scanned tag. +#[derive(Deserialize)] +pub struct NfcTagRecord { + /// The Type Name Format (TNF) of the record, which defines how [`Self::kind`] is interpreted. + pub tnf: NFCTypeNameFormat, + /// The record type bytes. + pub kind: Vec, + /// The record identifier bytes. Can be empty. + pub id: Vec, + /// The record payload bytes. + pub payload: Vec, +} + +/// An NFC tag that has been scanned. +#[derive(Deserialize)] +pub struct NfcTag { + /// The tag identifier, as reported by the operating system. + pub id: String, + /// The technology the tag supports. + pub kind: String, + /// The NDEF records stored on the tag. Empty when the tag holds no NDEF message. + pub records: Vec, +} + +/// Response of the [`Nfc::scan`](crate::Nfc::scan) API. +#[derive(Deserialize)] +pub struct ScanResponse { + /// The tag that has been scanned. + pub tag: NfcTag, +} + +/// Filters the tags to scan by the URI of their NDEF payload. +/// +/// Every field is optional and only the ones that are set take part in the filter. +/// **Android only**: the iOS implementation ignores this filter. +#[derive(Debug, Default, Serialize)] +pub struct UriFilter { + /// Only match URIs with this scheme, e.g. `https`. + scheme: Option, + /// Only match URIs with this authority (host), e.g. `tauri.app`. + host: Option, + /// Only match URIs whose path starts with this prefix, e.g. `/docs`. + path_prefix: Option, +} + +/// The NFC technologies a tag can support, mirroring the `android.nfc.tech` classes. +/// +/// **Android only**. Serialized as the technology name, e.g. `"IsoDep"`. +#[derive(Debug)] +pub enum TechKind { + /// ISO-DEP (ISO 14443-4) properties and I/O operations. + IsoDep, + /// MIFARE Classic properties and I/O operations. + MifareClassic, + /// MIFARE Ultralight and MIFARE Ultralight C properties and I/O operations. + MifareUltralight, + /// NDEF data and operations on tags that are already formatted as NDEF. + Ndef, + /// Formatting operations on tags that can be formatted as NDEF but are not yet. + NdefFormatable, + /// NFC-A (ISO 14443-3A) properties and I/O operations. + NfcA, + /// NFC-B (ISO 14443-3B) properties and I/O operations. + NfcB, + /// NFC Barcode (Kovio NFC Barcode) properties and I/O operations. + NfcBarcode, + /// NFC-F (JIS 6319-4) properties and I/O operations. + NfcF, + /// NFC-V (ISO 15693) properties and I/O operations. + NfcV, +} + +impl Display for TechKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::IsoDep => "IsoDep", + Self::MifareClassic => "MifareClassic", + Self::MifareUltralight => "MifareUltralight", + Self::Ndef => "Ndef", + Self::NdefFormatable => "NdefFormatable", + Self::NfcA => "NfcA", + Self::NfcB => "NfcB", + Self::NfcBarcode => "NfcBarcode", + Self::NfcF => "NfcF", + Self::NfcV => "NfcV", + } + ) + } +} + +impl Serialize for TechKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +/// The kind of scan to perform, which defines which tags are matched. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ScanKind { + /// Only match tags that carry an NDEF message. + Ndef { + /// Only match tags whose NDEF payload has this MIME type, e.g. `text/plain`. + /// **Android only**. + mime_type: Option, + /// Only match tags whose NDEF payload URI matches this filter. **Android only**. + uri: Option, + /// Only match tags supporting the listed technologies. + /// + /// Each tech list is considered independently and the tag matches when any single tech + /// list matches it, which provides AND (inside a list) and OR (between lists) semantics. + /// + /// **Android only**. See + /// + /// for more information. + tech_list: Option>>, + }, + /// Match any tag that is discovered, whether it carries an NDEF message or not. + Tag { + /// Only match tags whose payload has this MIME type, e.g. `text/plain`. **Android only**. + mime_type: Option, + /// Only match tags whose payload URI matches this filter. **Android only**. + uri: Option, + }, +} diff --git a/plugins/nfc/tsconfig.json b/plugins/nfc/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/nfc/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/notification/CHANGELOG.md b/plugins/notification/CHANGELOG.md new file mode 100644 index 0000000..48c2f4a --- /dev/null +++ b/plugins/notification/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +## [2.4.0] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. +- [`304292d7`](https://github.com/tauri-apps/plugins-workspace/commit/304292d740a666d3903ab58329860fc0fdde2818) ([#2805](https://github.com/tauri-apps/plugins-workspace/pull/2805) by [@Innominus](https://github.com/tauri-apps/plugins-workspace/../../Innominus)) - Add Rust builders for mobile notification action types. + - Fix Android action-group entries being stored under the action type ID instead of sequential indices, causing later actions to overwrite earlier ones. + +## \[2.3.3] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.2] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. + +## \[2.3.1] + +- [`8abb31ee`](https://github.com/tauri-apps/plugins-workspace/commit/8abb31ee59c68197102c0aa699d690b34646ec3c) ([#2905](https://github.com/tauri-apps/plugins-workspace/pull/2905) by [@ChristianPavilonis](https://github.com/tauri-apps/plugins-workspace/../../ChristianPavilonis)) Fix notification scheduling on iOS. +- [`2d03e2ea`](https://github.com/tauri-apps/plugins-workspace/commit/2d03e2eac2c19ad997d81d23836ab6a219252ffb) ([#2678](https://github.com/tauri-apps/plugins-workspace/pull/2678) by [@Keerthi421](https://github.com/tauri-apps/plugins-workspace/../../Keerthi421)) Added sound support for desktop notifications which was previously only available on mobile platforms. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.3] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.2] + +- [`a1b3fa27`](https://github.com/tauri-apps/plugins-workspace/commit/a1b3fa27f11022c9b6622b4fab12d93239eb05de) ([#2515](https://github.com/tauri-apps/plugins-workspace/pull/2515) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Re-exported the `Geolocation`, `Haptics`, `Notification`, and `Os` structs so that they show up on docs.rs. + +## \[2.2.1] + +- [`da5c59e2`](https://github.com/tauri-apps/plugins-workspace/commit/da5c59e2fe879d177e3cfd52fcacce85440423cb) ([#2271](https://github.com/tauri-apps/plugins-workspace/pull/2271) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated `zbus` dependency to version 5. No API changes. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.5] + +- [`fb85e5dd`](https://github.com/tauri-apps/plugins-workspace/commit/fb85e5dd76688f3ae836890160f9bde843b70167) ([#1785](https://github.com/tauri-apps/plugins-workspace/pull/1785)) Update to tauri 2.0.0-rc.12. + +## \[2.0.0-rc.4] + +- [`3d301c65`](https://github.com/tauri-apps/plugins-workspace/commit/3d301c654e6f5e7f343e0e0cbb57648002e98f04) ([#1737](https://github.com/tauri-apps/plugins-workspace/pull/1737) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) The notification body is now optional on iOS to match the other platforms. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Use `PermissionState` from the `tauri` crate, which now also includes a "prompt with rationale" variant for Android (returned when your app must explain to the user why it needs the permission). +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) **Breaking change**: The permission type when using the API is now `'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'` instead of `'granted' | 'denied' | 'default'` for consistency with Rust types. When using the `window.Notification` API the type is unchanged to match the Web API type. +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.11] + +- [`725ff429`](https://github.com/tauri-apps/plugins-workspace/commit/725ff4295e56df9c30c099813bd64b96fe61b945) ([#1556](https://github.com/tauri-apps/plugins-workspace/pull/1556) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused the `notification` plugin's initialization script to cause the WebView on Windows to throw a `STATUS_ACCESS_VIOLATION` error on remote websites. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.8] + +- [`3779fb50`](https://github.com/tauri-apps/plugins-workspace/commit/3779fb50634fba4d7e7eb0bfecc2216349b9d64d) ([#1432](https://github.com/tauri-apps/plugins-workspace/pull/1432) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Use notify_rust from crates.io instead of local fork. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.4] + +- [`326df688`](https://github.com/tauri-apps/plugins-workspace/commit/326df6883998d416fc0837583ed972854628bb52)([#1236](https://github.com/tauri-apps/plugins-workspace/pull/1236)) Fixes command argument parsing on iOS. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. +- [`62ce5df`](https://github.com/tauri-apps/plugins-workspace/commit/62ce5df52ca3c86786e711ef193a206e7b0dc0cf)([#1096](https://github.com/tauri-apps/plugins-workspace/pull/1096)) Fix development mode check to set the app ID on macOS. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`1b1d795`](https://github.com/tauri-apps/plugins-workspace/commit/1b1d795b5866e5524a9a9925f0fb7b2f8e3e3675)([#874](https://github.com/tauri-apps/plugins-workspace/pull/874)) Export the missing `Schedule` class. +- [`8dea78a`](https://github.com/tauri-apps/plugins-workspace/commit/8dea78ac7dcb502159e66bad464094696aa257d4)([#909](https://github.com/tauri-apps/plugins-workspace/pull/909)) Fixes deserialization and implementation bugs with scheduled notifications on Android. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.3] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.1] + +- [`d8b4aca`](https://github.com/tauri-apps/plugins-workspace/commit/d8b4aca69f628b170804ecb982e2c319d026ef47)([#414](https://github.com/tauri-apps/plugins-workspace/pull/414)) Use `window.__TAURI_INVOKE__` instead of `window.__TAURI__` in init.js, fixes usage in apps without `withGlobalTauri` enabled. +- [`7d71ad4`](https://github.com/tauri-apps/plugins-workspace/commit/7d71ad4e587bcf47ea34645f5b226945e487b765) Play a default sound when showing a notification on Windows. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml new file mode 100644 index 0000000..b306b5f --- /dev/null +++ b/plugins/notification/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "tauri-plugin-notification" +version = "2.4.0" +description = "Send desktop and mobile notifications on your Tauri application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-notification" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/test", + "/tsconfig.json", +] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu", "x86_64-linux-android"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "Only works for installed apps. Shows powershell name & icon in development." } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +rand = "0.9" +time = { version = "0.3", features = ["serde", "parsing", "formatting"] } +url = { version = "2", features = ["serde"] } +serde_repr = "0.1" + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } + +[target."cfg(windows)".dependencies] +win7-notifications = { version = "0.4.5", optional = true } +windows-version = { version = "0.1", optional = true } + +[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] +notify-rust = "4.11" + +[features] +windows7-compat = ["win7-notifications", "windows-version"] diff --git a/plugins/notification/LICENSE.spdx b/plugins/notification/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/notification/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/notification/LICENSE_APACHE-2.0 b/plugins/notification/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/notification/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/notification/LICENSE_MIT b/plugins/notification/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/notification/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/notification/README.md b/plugins/notification/README.md new file mode 100644 index 0000000..e17efe0 --- /dev/null +++ b/plugins/notification/README.md @@ -0,0 +1,161 @@ +![plugin-notification](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/notification/banner.png) + +Send message notifications (brief auto-expiring OS window element) to your user. Can also be used with the Notification Web API. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-notification = "2.0.0" +# alternatively with Git: +tauri-plugin-notification = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-notification +# or +npm add @tauri-apps/plugin-notification +# or +yarn add @tauri-apps/plugin-notification +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_notification::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Then you need to add the permissions to your capabilities file: + +`src-tauri/capabilities/main.json` + +```json +{ + ... + "permissions": [ + ... + "notification:default" + ], + ... +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { + isPermissionGranted, + requestPermission, + sendNotification +} from '@tauri-apps/plugin-notification' + +async function checkPermission() { + if (!(await isPermissionGranted())) { + return (await requestPermission()) === 'granted' + } + return true +} + +export async function enqueueNotification(title, body) { + if (!(await checkPermission())) { + return + } + sendNotification({ title, body }) +} +``` + +### Notification with Sound + +You can add sound to your notifications on all platforms (desktop and mobile): + +```javascript +import { sendNotification } from '@tauri-apps/plugin-notification' +import { platform } from '@tauri-apps/api/os' + +// Basic notification with sound +sendNotification({ + title: 'New Message', + body: 'You have a new message', + sound: 'notification.wav' // Path to sound file +}) + +// Platform-specific sounds +async function sendPlatformSpecificNotification() { + const platformName = platform() + + let soundPath + if (platformName === 'darwin') { + // On macOS: use system sounds or sound files in the app bundle + soundPath = 'Ping' // macOS system sound + } else if (platformName === 'linux') { + // On Linux: use XDG theme sounds or file paths + soundPath = 'message-new-instant' // XDG theme sound + } else { + // On Windows: use file paths + soundPath = 'notification.wav' + } + + sendNotification({ + title: 'Platform-specific Notification', + body: 'This notification uses platform-specific sound', + sound: soundPath + }) +} +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/notification/SECURITY.md b/plugins/notification/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/notification/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/notification/android/.gitignore b/plugins/notification/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/notification/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/notification/android/build.gradle.kts b/plugins/notification/android/build.gradle.kts new file mode 100644 index 0000000..f2b4cf6 --- /dev/null +++ b/plugins/notification/android/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.notification" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/plugins/notification/android/consumer-rules.pro b/plugins/notification/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/notification/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/notification/android/settings.gradle b/plugins/notification/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/notification/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/notification/android/src/androidTest/java/ExampleInstrumentedTest.kt b/plugins/notification/android/src/androidTest/java/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..88ede7f --- /dev/null +++ b/plugins/notification/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("app.tauri.notification", appContext.packageName) + } +} diff --git a/plugins/notification/android/src/main/AndroidManifest.xml b/plugins/notification/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c49808e --- /dev/null +++ b/plugins/notification/android/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/AssetUtils.kt b/plugins/notification/android/src/main/java/AssetUtils.kt new file mode 100644 index 0000000..fafccf7 --- /dev/null +++ b/plugins/notification/android/src/main/java/AssetUtils.kt @@ -0,0 +1,29 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.annotation.SuppressLint +import android.content.Context + +class AssetUtils { + companion object { + const val RESOURCE_ID_ZERO_VALUE = 0 + + @SuppressLint("DiscouragedApi") + fun getResourceID(context: Context, resourceName: String?, dir: String?): Int { + return context.resources.getIdentifier(resourceName, dir, context.packageName) + } + + fun getResourceBaseName(resPath: String?): String? { + if (resPath == null) return null + if (resPath.contains("/")) { + return resPath.substring(resPath.lastIndexOf('/') + 1) + } + return if (resPath.contains(".")) { + resPath.substring(0, resPath.lastIndexOf('.')) + } else resPath + } + } +} diff --git a/plugins/notification/android/src/main/java/ChannelManager.kt b/plugins/notification/android/src/main/java/ChannelManager.kt new file mode 100644 index 0000000..206f340 --- /dev/null +++ b/plugins/notification/android/src/main/java/ChannelManager.kt @@ -0,0 +1,151 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentResolver +import android.content.Context +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import app.tauri.Logger +import app.tauri.annotation.InvokeArg +import app.tauri.plugin.Invoke +import com.fasterxml.jackson.annotation.JsonValue + +enum class Importance(@JsonValue val value: Int) { + None(0), + Min(1), + Low(2), + Default(3), + High(4); +} + +enum class Visibility(@JsonValue val value: Int) { + Secret(-1), + Private(0), + Public(1); +} + +@InvokeArg +class Channel { + lateinit var id: String + lateinit var name: String + var description: String? = null + var sound: String? = null + var lights: Boolean? = null + var lightsColor: String? = null + var vibration: Boolean? = null + var importance: Importance? = null + var visibility: Visibility? = null +} + +@InvokeArg +class DeleteChannelArgs { + lateinit var id: String +} + +class ChannelManager(private var context: Context) { + private var notificationManager: NotificationManager? = null + + init { + notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + } + + fun createChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = invoke.parseArgs(Channel::class.java) + createChannel(channel) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + private fun createChannel(channel: Channel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channel.id, + channel.name, + (channel.importance ?: Importance.Default).value + ) + notificationChannel.description = channel.description + notificationChannel.lockscreenVisibility = (channel.visibility ?: Visibility.Private).value + notificationChannel.enableVibration(channel.vibration ?: false) + notificationChannel.enableLights(channel.lights ?: false) + val lightColor = channel.lightsColor ?: "" + if (lightColor.isNotEmpty()) { + try { + notificationChannel.lightColor = Color.parseColor(lightColor) + } catch (ex: IllegalArgumentException) { + Logger.error( + Logger.tags("NotificationChannel"), + "Invalid color provided for light color.", + null + ) + } + } + var sound = channel.sound ?: "" + if (sound.isNotEmpty()) { + if (sound.contains(".")) { + sound = sound.substring(0, sound.lastIndexOf('.')) + } + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + val soundUri = + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/" + sound) + notificationChannel.setSound(soundUri, audioAttributes) + } + notificationManager?.createNotificationChannel(notificationChannel) + } + } + + fun deleteChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val args = invoke.parseArgs(DeleteChannelArgs::class.java) + notificationManager?.deleteNotificationChannel(args.id) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + fun listChannels(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannels: List = + notificationManager?.notificationChannels ?: listOf() + + val channels = mutableListOf() + + for (notificationChannel in notificationChannels) { + val channel = Channel() + channel.id = notificationChannel.id + channel.name = notificationChannel.name.toString() + channel.description = notificationChannel.description + channel.sound = notificationChannel.sound.toString() + channel.lights = notificationChannel.shouldShowLights() + String.format( + "#%06X", + 0xFFFFFF and notificationChannel.lightColor + ) + channel.vibration = notificationChannel.shouldVibrate() + channel.importance = Importance.values().firstOrNull { it.value == notificationChannel.importance } + channel.visibility = Visibility.values().firstOrNull { it.value == notificationChannel.lockscreenVisibility } + + channels.add(channel) + } + + invoke.resolveObject(channels) + + } else { + invoke.reject("channel not available") + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/Notification.kt b/plugins/notification/android/src/main/java/Notification.kt new file mode 100644 index 0000000..bf10f3d --- /dev/null +++ b/plugins/notification/android/src/main/java/Notification.kt @@ -0,0 +1,95 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.tauri.annotation.InvokeArg +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONException +import org.json.JSONObject + +@InvokeArg +class Notification { + var id: Int = 0 + var title: String? = null + var body: String? = null + var largeBody: String? = null + var summary: String? = null + var sound: String? = null + var icon: String? = null + var largeIcon: String? = null + var iconColor: String? = null + var actionTypeId: String? = null + var group: String? = null + var inboxLines: List? = null + var isGroupSummary = false + var isOngoing = false + var isAutoCancel = false + var extra: JSObject? = null + var attachments: List? = null + var schedule: NotificationSchedule? = null + var channelId: String? = null + var sourceJson: String? = null + var visibility: Int? = null + var number: Int? = null + + fun getSound(context: Context, defaultSound: Int): String? { + var soundPath: String? = null + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val name = AssetUtils.getResourceBaseName(sound) + if (name != null) { + resId = AssetUtils.getResourceID(context, name, "raw") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultSound + } + if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + soundPath = + ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + resId + } + return soundPath + } + + fun getIconColor(globalColor: String): String { + // use the one defined local before trying for a globally defined color + return iconColor ?: globalColor + } + + fun getSmallIcon(context: Context, defaultIcon: Int): Int { + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + if (icon != null) { + resId = AssetUtils.getResourceID(context, icon, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultIcon + } + return resId + } + + fun getLargeIcon(context: Context): Bitmap? { + if (largeIcon != null) { + val resId: Int = AssetUtils.getResourceID(context, largeIcon, "drawable") + return BitmapFactory.decodeResource(context.resources, resId) + } + return null + } + + companion object { + fun buildNotificationPendingList(notifications: List): List { + val pendingNotifications = mutableListOf() + for (notification in notifications) { + val pendingNotification = PendingNotification(notification.id, notification.title, notification.body, notification.schedule, notification.extra) + pendingNotifications.add(pendingNotification) + } + return pendingNotifications + } + } +} + +class PendingNotification(val id: Int, val title: String?, val body: String?, val schedule: NotificationSchedule?, val extra: JSObject?) \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationAttachment.kt b/plugins/notification/android/src/main/java/NotificationAttachment.kt new file mode 100644 index 0000000..56a1381 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationAttachment.kt @@ -0,0 +1,52 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import app.tauri.plugin.JSObject +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class NotificationAttachment { + var id: String? = null + var url: String? = null + var options: JSONObject? = null + + companion object { + fun getAttachments(notification: JSObject): List { + val attachmentsList: MutableList = ArrayList() + var attachments: JSONArray? = null + try { + attachments = notification.getJSONArray("attachments") + } catch (_: Exception) { + } + if (attachments != null) { + for (i in 0 until attachments.length()) { + val newAttachment = NotificationAttachment() + var jsonObject: JSONObject? = null + try { + jsonObject = attachments.getJSONObject(i) + } catch (e: JSONException) { + } + if (jsonObject != null) { + var jsObject: JSObject? = null + try { + jsObject = JSObject.fromJSONObject(jsonObject) + } catch (_: JSONException) { + } + newAttachment.id = jsObject!!.getString("id") + newAttachment.url = jsObject.getString("url") + try { + newAttachment.options = jsObject.getJSONObject("options") + } catch (_: JSONException) { + } + attachmentsList.add(newAttachment) + } + } + } + return attachmentsList + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt new file mode 100644 index 0000000..19a8283 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -0,0 +1,286 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.webkit.WebView +import app.tauri.PermissionState +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.Permission +import app.tauri.annotation.PermissionCallback +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +const val LOCAL_NOTIFICATIONS = "permissionState" + +@InvokeArg +class PluginConfig { + var icon: String? = null + var sound: String? = null + var iconColor: String? = null +} + +@InvokeArg +class BatchArgs { + lateinit var notifications: List +} + +@InvokeArg +class CancelArgs { + // `cancelAll()` sends no list: cancel every pending notification + var notifications: List? = null +} + +@InvokeArg +class NotificationAction { + lateinit var id: String + var title: String? = null + var input: Boolean? = null +} + +@InvokeArg +class ActionType { + lateinit var id: String + lateinit var actions: List +} + +@InvokeArg +class RegisterActionTypesArgs { + lateinit var types: List +} + +@InvokeArg +class ActiveNotification { + var id: Int = 0 + var tag: String? = null +} + +@InvokeArg +class RemoveActiveArgs { + var notifications: List = listOf() +} + +@TauriPlugin( + permissions = [ + Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState") + ] +) +class NotificationPlugin(private val activity: Activity): Plugin(activity) { + private var webView: WebView? = null + private lateinit var manager: TauriNotificationManager + private lateinit var notificationManager: NotificationManager + private lateinit var notificationStorage: NotificationStorage + private var channelManager = ChannelManager(activity) + + companion object { + var instance: NotificationPlugin? = null + + fun triggerNotification(notification: Notification) { + instance?.triggerObject("notification", notification) + } + } + + override fun load(webView: WebView) { + instance = this + + super.load(webView) + this.webView = webView + notificationStorage = NotificationStorage(activity, jsonMapper()) + + val manager = TauriNotificationManager( + notificationStorage, + activity, + activity, + getConfig(PluginConfig::class.java) + ) + manager.createNotificationChannel() + + this.manager = manager + + notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val intent = activity.intent + intent?.let { + onIntent(it) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + onIntent(intent) + } + + fun onIntent(intent: Intent) { + if (Intent.ACTION_MAIN != intent.action) { + return + } + val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) + if (dataJson != null) { + trigger("actionPerformed", dataJson) + } + } + + @Command + fun show(invoke: Invoke) { + val notification = invoke.parseArgs(Notification::class.java) + val id = manager.schedule(notification) + + invoke.resolveObject(id) + } + + @Command + fun batch(invoke: Invoke) { + val args = invoke.parseArgs(BatchArgs::class.java) + + val ids = manager.schedule(args.notifications) + notificationStorage.appendNotifications(args.notifications) + + invoke.resolveObject(ids) + } + + @Command + fun cancel(invoke: Invoke) { + val args = invoke.parseArgs(CancelArgs::class.java) + manager.cancel( + args.notifications ?: notificationStorage.getSavedNotificationIds().mapNotNull { it.toIntOrNull() } + ) + invoke.resolve() + } + + @Command + fun removeActive(invoke: Invoke) { + val args = invoke.parseArgs(RemoveActiveArgs::class.java) + + if (args.notifications.isEmpty()) { + notificationManager.cancelAll() + invoke.resolve() + } else { + for (notification in args.notifications) { + if (notification.tag == null) { + notificationManager.cancel(notification.id) + } else { + notificationManager.cancel(notification.tag, notification.id) + } + } + invoke.resolve() + } + } + + @Command + fun getPending(invoke: Invoke) { + val notifications= notificationStorage.getSavedNotifications() + val result = Notification.buildNotificationPendingList(notifications) + invoke.resolveObject(result) + } + + @Command + fun registerActionTypes(invoke: Invoke) { + val args = invoke.parseArgs(RegisterActionTypesArgs::class.java) + notificationStorage.writeActionGroup(args.types) + invoke.resolve() + } + + @SuppressLint("ObsoleteSdkInt") + @Command + fun getActive(invoke: Invoke) { + // plain lists and maps: until tauri serializes org.json values natively, + // `resolveObject` turns a `JSArray` into `{ "values": [...] }` + val notifications = mutableListOf>() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val activeNotifications = notificationManager.activeNotifications + for (activeNotification in activeNotifications) { + val jsNotification = mutableMapOf( + "id" to activeNotification.id, + "tag" to activeNotification.tag + ) + val notification = activeNotification.notification + if (notification != null) { + jsNotification["title"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)?.toString() + jsNotification["body"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString() + jsNotification["group"] = notification.group + jsNotification["groupSummary"] = + 0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY + val extras = mutableMapOf() + for (key in notification.extras.keySet()) { + extras[key] = notification.extras.getString(key) + } + jsNotification["data"] = extras + } + notifications.add(jsNotification) + } + } + + invoke.resolveObject(notifications) + } + + @Command + fun createChannel(invoke: Invoke) { + channelManager.createChannel(invoke) + } + + @Command + fun deleteChannel(invoke: Invoke) { + channelManager.deleteChannel(invoke) + } + + @Command + fun listChannels(invoke: Invoke) { + channelManager.listChannels(invoke) + } + + @Command + override fun checkPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } else { + super.checkPermissions(invoke) + } + } + + @Command + override fun requestPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + permissionState(invoke) + } else { + if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "permissionsCallback") + } + } + } + + @Command + fun permissionState(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } + + @PermissionCallback + private fun permissionsCallback(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } + + private fun getPermissionState(): String { + return if (manager.areNotificationsEnabled()) { + "granted" + } else { + "denied" + } + } +} diff --git a/plugins/notification/android/src/main/java/NotificationSchedule.kt b/plugins/notification/android/src/main/java/NotificationSchedule.kt new file mode 100644 index 0000000..316ff90 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationSchedule.kt @@ -0,0 +1,369 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.annotation.SuppressLint +import android.text.format.DateUtils +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.TimeZone + +const val JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + +enum class NotificationInterval { + @JsonProperty("year") + Year, + @JsonProperty("month") + Month, + @JsonProperty("twoWeeks") + TwoWeeks, + @JsonProperty("week") + Week, + @JsonProperty("day") + Day, + @JsonProperty("hour") + Hour, + @JsonProperty("minute") + Minute, + @JsonProperty("second") + Second +} + +fun getIntervalTime(interval: NotificationInterval, count: Int): Long { + return when (interval) { + // This case is just approximation as not all years have the same number of days + NotificationInterval.Year -> count * DateUtils.WEEK_IN_MILLIS * 52 + // This case is just approximation as months have different number of days + NotificationInterval.Month -> count * 30 * DateUtils.DAY_IN_MILLIS + NotificationInterval.TwoWeeks -> count * 2 * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Week -> count * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Day -> count * DateUtils.DAY_IN_MILLIS + NotificationInterval.Hour -> count * DateUtils.HOUR_IN_MILLIS + NotificationInterval.Minute -> count * DateUtils.MINUTE_IN_MILLIS + NotificationInterval.Second -> count * DateUtils.SECOND_IN_MILLIS + } +} + +@JsonDeserialize(using = NotificationScheduleDeserializer::class) +@JsonSerialize(using = NotificationScheduleSerializer::class) +sealed class NotificationSchedule { + // At specific moment of time (with repeating option) + @JsonDeserialize + class At: NotificationSchedule() { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = JS_DATE_FORMAT) + lateinit var date: Date + var repeating: Boolean = false + var allowWhileIdle: Boolean = false + } + @JsonDeserialize + class Interval: NotificationSchedule() { + lateinit var interval: DateMatch + var allowWhileIdle: Boolean = false + } + @JsonDeserialize + class Every: NotificationSchedule() { + lateinit var interval: NotificationInterval + var count: Int = 0 + var allowWhileIdle: Boolean = false + } + + fun isRemovable(): Boolean { + return when (this) { + is At -> !repeating + else -> false + } + } + + fun allowWhileIdle(): Boolean { + return when (this) { + is At -> allowWhileIdle + is Interval -> allowWhileIdle + is Every -> allowWhileIdle + else -> false + } + } +} + +internal class NotificationScheduleSerializer @JvmOverloads constructor(t: Class? = null) : + StdSerializer(t) { + @SuppressLint("SimpleDateFormat") + @Throws(IOException::class, JsonProcessingException::class) + override fun serialize( + value: NotificationSchedule, jgen: JsonGenerator, provider: SerializerProvider + ) { + jgen.writeStartObject() + when (value) { + is NotificationSchedule.At -> { + jgen.writeObjectFieldStart("at") + + val sdf = SimpleDateFormat(JS_DATE_FORMAT) + sdf.timeZone = TimeZone.getTimeZone("UTC") + jgen.writeStringField("date", sdf.format(value.date)) + jgen.writeBooleanField("repeating", value.repeating) + + jgen.writeEndObject() + } + is NotificationSchedule.Interval -> { + jgen.writeObjectFieldStart("interval") + + jgen.writeObjectField("interval", value.interval) + + jgen.writeEndObject() + } + is NotificationSchedule.Every -> { + jgen.writeObjectFieldStart("every") + + jgen.writeObjectField("interval", value.interval) + jgen.writeNumberField("count", value.count) + + jgen.writeEndObject() + } + } + + jgen.writeEndObject() + } +} + +internal class NotificationScheduleDeserializer: JsonDeserializer() { + override fun deserialize( + jsonParser: JsonParser, + deserializationContext: DeserializationContext + ): NotificationSchedule { + val node: JsonNode = jsonParser.codec.readTree(jsonParser) + node.get("at")?.let { + return jsonParser.codec.treeToValue(it, NotificationSchedule.At::class.java) + } + node.get("interval")?.let { + return jsonParser.codec.treeToValue(it, NotificationSchedule.Interval::class.java) + } + node.get("every")?.let { + return jsonParser.codec.treeToValue(it, NotificationSchedule.Every::class.java) + } + throw Error("unknown schedule kind $node") + } +} + +class DateMatch { + var year: Int? = null + var month: Int? = null + var day: Int? = null + var weekday: Int? = null + var hour: Int? = null + var minute: Int? = null + var second: Int? = null + + // Unit used to save the last used unit for a trigger. + // One of the Calendar constants values + var unit: Int? = -1 + + /** + * Gets a calendar instance pointing to the specified date. + * + * @param date The date to point. + */ + private fun buildCalendar(date: Date): Calendar { + val cal: Calendar = Calendar.getInstance() + cal.time = date + cal.set(Calendar.MILLISECOND, 0) + return cal + } + + /** + * Calculates next trigger date for + * + * @param date base date used to calculate trigger + * @return next trigger timestamp + */ + fun nextTrigger(date: Date): Long { + val current: Calendar = buildCalendar(date) + val next: Calendar = buildNextTriggerTime(date) + return postponeTriggerIfNeeded(current, next) + } + + /** + * Postpone trigger if first schedule matches the past + */ + private fun postponeTriggerIfNeeded(current: Calendar, next: Calendar): Long { + if (next.timeInMillis <= current.timeInMillis && unit != -1) { + var incrementUnit = -1 + if (unit == Calendar.YEAR || unit == Calendar.MONTH) { + incrementUnit = Calendar.YEAR + } else if (unit == Calendar.DAY_OF_MONTH) { + incrementUnit = Calendar.MONTH + } else if (unit == Calendar.DAY_OF_WEEK) { + incrementUnit = Calendar.WEEK_OF_MONTH + } else if (unit == Calendar.HOUR_OF_DAY) { + incrementUnit = Calendar.DAY_OF_MONTH + } else if (unit == Calendar.MINUTE) { + incrementUnit = Calendar.HOUR_OF_DAY + } else if (unit == Calendar.SECOND) { + incrementUnit = Calendar.MINUTE + } + if (incrementUnit != -1) { + next.set(incrementUnit, next.get(incrementUnit) + 1) + } + } + return next.timeInMillis + } + + private fun buildNextTriggerTime(date: Date): Calendar { + val next: Calendar = buildCalendar(date) + if (year != null) { + next.set(Calendar.YEAR, year ?: 0) + if (unit == -1) unit = Calendar.YEAR + } + if (month != null) { + next.set(Calendar.MONTH, month ?: 0) + if (unit == -1) unit = Calendar.MONTH + } + if (day != null) { + next.set(Calendar.DAY_OF_MONTH, day ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_MONTH + } + if (weekday != null) { + next.set(Calendar.DAY_OF_WEEK, weekday ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_WEEK + } + if (hour != null) { + next.set(Calendar.HOUR_OF_DAY, hour ?: 0) + if (unit == -1) unit = Calendar.HOUR_OF_DAY + } + if (minute != null) { + next.set(Calendar.MINUTE, minute ?: 0) + if (unit == -1) unit = Calendar.MINUTE + } + if (second != null) { + next.set(Calendar.SECOND, second ?: 0) + if (unit == -1) unit = Calendar.SECOND + } + return next + } + + override fun toString(): String { + return "DateMatch{" + + "year=" + + year + + ", month=" + + month + + ", day=" + + day + + ", weekday=" + + weekday + + ", hour=" + + hour + + ", minute=" + + minute + + ", second=" + + second + + '}' + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val dateMatch = other as DateMatch + if (if (year != null) year != dateMatch.year else dateMatch.year != null) return false + if (if (month != null) month != dateMatch.month else dateMatch.month != null) return false + if (if (day != null) day != dateMatch.day else dateMatch.day != null) return false + if (if (weekday != null) weekday != dateMatch.weekday else dateMatch.weekday != null) return false + if (if (hour != null) hour != dateMatch.hour else dateMatch.hour != null) return false + if (if (minute != null) minute != dateMatch.minute else dateMatch.minute != null) return false + return if (second != null) second == dateMatch.second else dateMatch.second == null + } + + override fun hashCode(): Int { + var result = if (year != null) year.hashCode() else 0 + result = 31 * result + if (month != null) month.hashCode() else 0 + result = 31 * result + if (day != null) day.hashCode() else 0 + result = 31 * result + if (weekday != null) weekday.hashCode() else 0 + result = 31 * result + if (hour != null) hour.hashCode() else 0 + result = 31 * result + if (minute != null) minute.hashCode() else 0 + result += 31 + if (second != null) second.hashCode() else 0 + return result + } + + /** + * Transform DateMatch object to CronString + * + * @return + */ + fun toMatchString(): String { + val matchString = year.toString() + + separator + + month + + separator + + day + + separator + + weekday + + separator + + hour + + separator + + minute + + separator + + second + + separator + + unit + return matchString.replace("null", "*") + } + + companion object { + private const val separator = " " + + /** + * Create DateMatch object from stored string + * + * @param matchString + * @return + */ + fun fromMatchString(matchString: String): DateMatch { + val date = DateMatch() + val split = matchString.split(separator.toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (split.size == 7) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.unit = getValueFromCronElement(split[6]) + } + if (split.size == 8) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.second = getValueFromCronElement(split[6]) + date.unit = getValueFromCronElement(split[7]) + } + return date + } + + private fun getValueFromCronElement(token: String): Int? { + return try { + token.toInt() + } catch (e: NumberFormatException) { + null + } + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt new file mode 100644 index 0000000..d4cb969 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -0,0 +1,113 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.content.Context +import android.content.SharedPreferences +import com.fasterxml.jackson.databind.ObjectMapper +import org.json.JSONException +import java.lang.Exception + +// Key for private preferences +private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" +// Key used to save action types +private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE" + +class NotificationStorage(private val context: Context, private val jsonMapper: ObjectMapper) { + fun appendNotifications(localNotifications: List) { + val storage = getStorage(NOTIFICATION_STORE_ID) + val editor = storage.edit() + for (request in localNotifications) { + if (request.schedule != null) { + val key: String = request.id.toString() + editor.putString(key, request.sourceJson.toString()) + } + } + editor.apply() + } + + fun getSavedNotificationIds(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + return if (all != null) { + ArrayList(all.keys) + } else ArrayList() + } + + fun getSavedNotifications(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + if (all != null) { + val notifications = ArrayList() + for (key in all.keys) { + val notificationString = all[key] as String? + try { + val notification = jsonMapper.readValue(notificationString, Notification::class.java) + notifications.add(notification) + } catch (_: Exception) { } + } + return notifications + } + return ArrayList() + } + + fun getSavedNotification(key: String): Notification? { + val storage = getStorage(NOTIFICATION_STORE_ID) + val notificationString = try { + storage.getString(key, null) + } catch (ex: ClassCastException) { + return null + } ?: return null + + return try { + jsonMapper.readValue(notificationString, Notification::class.java) + } catch (ex: JSONException) { + null + } + } + + fun deleteNotification(id: String?) { + val editor = getStorage(NOTIFICATION_STORE_ID).edit() + editor.remove(id) + editor.apply() + } + + private fun getStorage(key: String): SharedPreferences { + return context.getSharedPreferences(key, Context.MODE_PRIVATE) + } + + fun writeActionGroup(actions: List) { + for (type in actions) { + val editor = getStorage(ACTION_TYPES_ID + type.id).edit() + editor.clear() + editor.putInt("count", type.actions.size) + for (i in 0 until type.actions.size) { + val action = type.actions[i] + editor.putString("id$i", action.id) + editor.putString("title$i", action.title) + editor.putBoolean("input$i", action.input ?: false) + } + editor.apply() + } + } + + fun getActionGroup(forId: String): Array { + val storage = getStorage(ACTION_TYPES_ID + forId) + val count = storage.getInt("count", 0) + val actions: Array = arrayOfNulls(count) + for (i in 0 until count) { + val id = storage.getString("id$i", "") + val title = storage.getString("title$i", "") + val input = storage.getBoolean("input$i", false) + + val action = NotificationAction() + action.id = id ?: "" + action.title = title + action.input = input + actions[i] = action + } + return actions + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt new file mode 100644 index 0000000..a891273 --- /dev/null +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -0,0 +1,581 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.UserManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import app.tauri.Logger +import app.tauri.plugin.JSObject +import app.tauri.plugin.PluginManager +import com.fasterxml.jackson.databind.ObjectMapper +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date + +// Action constants +const val NOTIFICATION_INTENT_KEY = "NotificationId" +const val NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject" +const val ACTION_INTENT_KEY = "NotificationUserAction" +const val NOTIFICATION_IS_REMOVABLE_KEY = "NotificationRepeating" +const val REMOTE_INPUT_KEY = "NotificationRemoteInput" +const val DEFAULT_NOTIFICATION_CHANNEL_ID = "default" +const val DEFAULT_PRESS_ACTION = "tap" + +class TauriNotificationManager( + private val storage: NotificationStorage, + private val activity: Activity?, + private val context: Context, + private val config: PluginConfig? +) { + private var defaultSoundID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + private var defaultSmallIconID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + + fun handleNotificationActionPerformed( + data: Intent, + notificationStorage: NotificationStorage + ): JSObject? { + Logger.debug(Logger.tags("Notification"), "Notification received: " + data.dataString) + val notificationId = + data.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (notificationId == Int.MIN_VALUE) { + Logger.debug(Logger.tags("Notification"), "Activity started without notification attached") + return null + } + val isRemovable = + data.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + notificationStorage.deleteNotification(notificationId.toString()) + } + val dataJson = JSObject() + val results = RemoteInput.getResultsFromIntent(data) + val input = results?.getCharSequence(REMOTE_INPUT_KEY) + dataJson.put("inputValue", input?.toString()) + val menuAction = data.getStringExtra(ACTION_INTENT_KEY) + dismissVisibleNotification(notificationId) + dataJson.put("actionId", menuAction) + var request: JSONObject? = null + try { + val notificationJsonString = + data.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY) + if (notificationJsonString != null) { + request = JSObject(notificationJsonString) + } + } catch (_: JSONException) { + } + dataJson.put("notification", request) + return dataJson + } + + /** + * Create notification channel + */ + fun createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Default" + val description = "Default" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + val soundUri = getDefaultSoundUrl(context) + if (soundUri != null) { + channel.setSound(soundUri, audioAttributes) + } + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + private fun trigger(notificationManager: NotificationManagerCompat, notification: Notification): Int { + dismissVisibleNotification(notification.id) + cancelTimerForNotification(notification.id) + buildNotification(notificationManager, notification) + + return notification.id + } + + fun schedule(notification: Notification): Int { + val notificationManager = NotificationManagerCompat.from(context) + return trigger(notificationManager, notification) + } + + fun schedule(notifications: List): List { + val ids = mutableListOf() + val notificationManager = NotificationManagerCompat.from(context) + + for (notification in notifications) { + val id = trigger(notificationManager, notification) + ids.add(id) + } + + return ids + } + + // TODO Progressbar support + // TODO System categories (DO_NOT_DISTURB etc.) + // TODO use NotificationCompat.MessagingStyle for latest API + // TODO expandable notification NotificationCompat.MessagingStyle + // TODO media style notification support NotificationCompat.MediaStyle + @SuppressLint("MissingPermission") + private fun buildNotification( + notificationManager: NotificationManagerCompat, + notification: Notification, + ) { + val channelId = notification.channelId ?: DEFAULT_NOTIFICATION_CHANNEL_ID + val mBuilder = NotificationCompat.Builder( + context, channelId + ) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setAutoCancel(notification.isAutoCancel) + .setOngoing(notification.isOngoing) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setGroupSummary(notification.isGroupSummary) + if (notification.largeBody != null) { + // support multiline text + mBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.largeBody) + .setSummaryText(notification.summary) + ) + } else if (notification.inboxLines != null) { + val inboxStyle = NotificationCompat.InboxStyle() + for (line in notification.inboxLines ?: listOf()) { + inboxStyle.addLine(line) + } + inboxStyle.setBigContentTitle(notification.title) + inboxStyle.setSummaryText(notification.summary) + mBuilder.setStyle(inboxStyle) + } + val sound = notification.getSound(context, getDefaultSound(context)) + if (sound != null) { + val soundUri = Uri.parse(sound) + // Grant permission to use sound + context.grantUriPermission( + "com.android.systemui", + soundUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + mBuilder.setSound(soundUri) + mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS) + } else { + mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL) + } + val group = notification.group + if (group != null) { + mBuilder.setGroup(group) + if (notification.isGroupSummary) { + mBuilder.setSubText(notification.summary) + } + } + mBuilder.setVisibility(notification.visibility ?: NotificationCompat.VISIBILITY_PRIVATE) + mBuilder.setOnlyAlertOnce(true) + mBuilder.setSmallIcon(notification.getSmallIcon(context, getDefaultSmallIcon(context))) + mBuilder.setLargeIcon(notification.getLargeIcon(context)) + val iconColor = notification.getIconColor(config?.iconColor ?: "") + if (iconColor.isNotEmpty()) { + try { + mBuilder.color = Color.parseColor(iconColor) + } catch (ex: IllegalArgumentException) { + throw Exception("Invalid color provided. Must be a hex string (ex: #ff0000") + } + } + createActionIntents(notification, mBuilder) + // notificationId is a unique int for each notification that you must define + val buildNotification = mBuilder.build() + if (notification.schedule != null) { + triggerScheduledNotification(buildNotification, notification) + } else { + notificationManager.notify(notification.id, buildNotification) + try { + NotificationPlugin.triggerNotification(notification) + } catch (_: JSONException) { + } + } + } + + // Create intents for open/dismiss actions + private fun createActionIntents( + notification: Notification, + mBuilder: NotificationCompat.Builder + ) { + // Open intent + val intent = buildIntent(notification, DEFAULT_PRESS_ACTION) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getActivity(context, notification.id, intent, flags) + mBuilder.setContentIntent(pendingIntent) + + // Build action types + val actionTypeId = notification.actionTypeId + if (actionTypeId != null) { + val actionGroup = storage.getActionGroup(actionTypeId) + for (notificationAction in actionGroup) { + // TODO Add custom icons to actions + val actionIntent = buildIntent(notification, notificationAction!!.id) + val actionPendingIntent = PendingIntent.getActivity( + context, + (notification.id) + notificationAction.id.hashCode(), + actionIntent, + flags + ) + val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder( + R.drawable.ic_transparent, + notificationAction.title, + actionPendingIntent + ) + if (notificationAction.input == true) { + val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel( + notificationAction.title + ).build() + actionBuilder.addRemoteInput(remoteInput) + } + mBuilder.addAction(actionBuilder.build()) + } + } + + // Dismiss intent + val dissmissIntent = Intent( + context, + NotificationDismissReceiver::class.java + ) + dissmissIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss") + val schedule = notification.schedule + dissmissIntent.putExtra( + NOTIFICATION_IS_REMOVABLE_KEY, + schedule == null || schedule.isRemovable() + ) + flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val deleteIntent = + PendingIntent.getBroadcast(context, notification.id, dissmissIntent, flags) + mBuilder.setDeleteIntent(deleteIntent) + } + + private fun buildIntent(notification: Notification, action: String?): Intent { + val intent = if (activity != null) { + Intent(context, activity.javaClass) + } else { + val packageName = context.packageName + context.packageManager.getLaunchIntentForPackage(packageName)!! + } + intent.action = Intent.ACTION_MAIN + intent.addCategory(Intent.CATEGORY_LAUNCHER) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + intent.putExtra(ACTION_INTENT_KEY, action) + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notification.sourceJson) + val schedule = notification.schedule + intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) + return intent + } + + /** + * Build a notification trigger, such as triggering each N seconds, or + * on a certain date "shape" (such as every first of the month) + */ + // TODO support different AlarmManager.RTC modes depending on priority + @SuppressLint("SimpleDateFormat") + private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val schedule = request.schedule + val notificationIntent = Intent( + context, + TimedNotificationPublisher::class.java + ) + notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.id) + notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + var pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + + when (schedule) { + is NotificationSchedule.At -> { + if (schedule.date.time < Date().time) { + Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null) + return + } + if (schedule.repeating) { + val interval: Long = schedule.date.time - Date().time + alarmManager.setRepeating(AlarmManager.RTC, schedule.date.time, interval, pendingIntent) + } else { + setExactIfPossible(alarmManager, schedule, schedule.date.time, pendingIntent) + } + } + is NotificationSchedule.Interval -> { + val trigger = schedule.interval.nextTrigger(Date()) + notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, schedule.interval.toMatchString()) + pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + setExactIfPossible(alarmManager, schedule, trigger, pendingIntent) + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + request.id + " will next fire at " + sdf.format(Date(trigger)) + ) + } + is NotificationSchedule.Every -> { + val everyInterval = getIntervalTime(schedule.interval, schedule.count) + val startTime: Long = Date().time + everyInterval + alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent) + } + else -> {} + } + } + + @SuppressLint("ObsoleteSdkInt", "MissingPermission") + private fun setExactIfPossible( + alarmManager: AlarmManager, + schedule: NotificationSchedule, + trigger: Long, + pendingIntent: PendingIntent + ) { + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.allowWhileIdle()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } + } else { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.allowWhileIdle()) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + } + } + + fun cancel(notifications: List) { + for (id in notifications) { + dismissVisibleNotification(id) + cancelTimerForNotification(id) + storage.deleteNotification(id.toString()) + } + } + + private fun cancelTimerForNotification(notificationId: Int) { + val intent = Intent(context, TimedNotificationPublisher::class.java) + var flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val pi = PendingIntent.getBroadcast(context, notificationId, intent, flags) + if (pi != null) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pi) + } + } + + private fun dismissVisibleNotification(notificationId: Int) { + val notificationManager = NotificationManagerCompat.from( + context + ) + notificationManager.cancel(notificationId) + } + + fun areNotificationsEnabled(): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return notificationManager.areNotificationsEnabled() + } + + private fun getDefaultSoundUrl(context: Context): Uri? { + val soundId = getDefaultSound(context) + return if (soundId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + soundId) + } else null + } + + private fun getDefaultSound(context: Context): Int { + if (defaultSoundID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSoundID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val soundConfigResourceName = AssetUtils.getResourceBaseName(config?.sound) + if (soundConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, soundConfigResourceName, "raw") + } + defaultSoundID = resId + return resId + } + + private fun getDefaultSmallIcon(context: Context): Int { + if (defaultSmallIconID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val smallIconConfigResourceName = AssetUtils.getResourceBaseName(config?.icon) + if (smallIconConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, smallIconConfigResourceName, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = android.R.drawable.ic_dialog_info + } + defaultSmallIconID = resId + return resId + } +} + +class NotificationDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val intExtra = + intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (intExtra == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "Invalid notification dismiss operation", null) + return + } + val isRemovable = + intent.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + val notificationStorage = NotificationStorage(context, ObjectMapper()) + notificationStorage.deleteNotification(intExtra.toString()) + } + } +} + +class TimedNotificationPublisher : BroadcastReceiver() { + /** + * Restore and present notification + */ + override fun onReceive(context: Context, intent: Intent) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + NOTIFICATION_KEY, + android.app.Notification::class.java + ) + } else { + getParcelableExtraLegacy(intent, NOTIFICATION_KEY) + } + notification?.`when` = System.currentTimeMillis() + val id = intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (id == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "No valid id supplied", null) + } + val storage = NotificationStorage(context, ObjectMapper()) + + val savedNotification = storage.getSavedNotification(id.toString()) + if (savedNotification != null) { + NotificationPlugin.triggerNotification(savedNotification) + } + + notificationManager.notify(id, notification) + if (!rescheduleNotificationIfNeeded(context, intent, id)) { + storage.deleteNotification(id.toString()) + } + } + + @Suppress("DEPRECATION") + private fun getParcelableExtraLegacy(intent: Intent, string: String): android.app.Notification? { + return intent.getParcelableExtra(string) + } + + @SuppressLint("MissingPermission", "SimpleDateFormat") + private fun rescheduleNotificationIfNeeded(context: Context, intent: Intent, id: Int): Boolean { + val dateString = intent.getStringExtra(CRON_KEY) + if (dateString != null) { + val date = DateMatch.fromMatchString(dateString) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val trigger = date.nextTrigger(Date()) + val clone = intent.clone() as Intent + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getBroadcast(context, id, clone, flags) + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + id + " will next fire at " + sdf.format(Date(trigger)) + ) + return true + } + return false + } + + companion object { + var NOTIFICATION_KEY = "NotificationPublisher.notification" + var CRON_KEY = "NotificationPublisher.cron" + } +} + +class LocalNotificationRestoreReceiver : BroadcastReceiver() { + @SuppressLint("ObsoleteSdkInt") + override fun onReceive(context: Context, intent: Intent) { + if (SDK_INT >= Build.VERSION_CODES.N) { + val um = context.getSystemService( + UserManager::class.java + ) + if (um == null || !um.isUserUnlocked) return + } + val storage = NotificationStorage(context, ObjectMapper()) + val ids = storage.getSavedNotificationIds() + val notifications = mutableListOf() + val updatedNotifications = mutableListOf() + for (id in ids) { + val notification = storage.getSavedNotification(id) ?: continue + val schedule = notification.schedule + if (schedule != null && schedule is NotificationSchedule.At) { + val at: Date = schedule.date + if (at.before(Date())) { + // modify the scheduled date in order to show notifications that would have been delivered while device was off. + val newDateTime = Date().time + 15 * 1000 + schedule.date = Date(newDateTime) + updatedNotifications.add(notification) + } + } + notifications.add(notification) + } + if (updatedNotifications.size > 0) { + storage.appendNotifications(updatedNotifications) + } + + var config: PluginConfig? = null + try { + config = PluginManager.loadConfig(context, "notification", PluginConfig::class.java) + } catch (ex: Exception) { + ex.printStackTrace() + } + val notificationManager = TauriNotificationManager(storage, null, context, config) + notificationManager.schedule(notifications) + } +} diff --git a/plugins/notification/android/src/main/res/drawable/ic_transparent.xml b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml new file mode 100644 index 0000000..fc1779e --- /dev/null +++ b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/plugins/notification/android/src/test/java/ExampleUnitTest.kt b/plugins/notification/android/src/test/java/ExampleUnitTest.kt new file mode 100644 index 0000000..134a27d --- /dev/null +++ b/plugins/notification/android/src/test/java/ExampleUnitTest.kt @@ -0,0 +1,21 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.notification + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js new file mode 100644 index 0000000..85f75b2 --- /dev/null +++ b/plugins/notification/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i,n){"use strict";var e,t,o;i.ScheduleEvery=void 0,(e=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",e.Month="month",e.TwoWeeks="twoWeeks",e.Week="week",e.Day="day",e.Hour="hour",e.Minute="minute",e.Second="second";return i.Importance=void 0,(t=i.Importance||(i.Importance={}))[t.None=0]="None",t[t.Min=1]="Min",t[t.Low=2]="Low",t[t.Default=3]="Default",t[t.High=4]="High",i.Visibility=void 0,(o=i.Visibility||(i.Visibility={}))[o.Secret=-1]="Secret",o[o.Private=0]="Private",o[o.Public=1]="Public",i.Schedule=class{static at(i,n=!1,e=!1){return{at:{date:i,repeating:n,allowWhileIdle:e},interval:void 0,every:void 0}}static interval(i,n=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:n},every:void 0}}static every(i,n,e=!1){return{at:void 0,interval:void 0,every:{interval:i,count:n,allowWhileIdle:e}}}},i.active=async function(){return await n.invoke("plugin:notification|get_active")},i.cancel=async function(i){await n.invoke("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await n.invoke("plugin:notification|cancel")},i.channels=async function(){return await n.invoke("plugin:notification|list_channels")},i.createChannel=async function(i){await n.invoke("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await n.invoke("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await n.addPluginListener("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await n.addPluginListener("notification","notification",i)},i.pending=async function(){return await n.invoke("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await n.invoke("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await n.invoke("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await n.invoke("plugin:notification|remove_active")},i.removeChannel=async function(i){await n.invoke("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})} diff --git a/plugins/notification/banner.png b/plugins/notification/banner.png new file mode 100644 index 0000000..f6460f3 Binary files /dev/null and b/plugins/notification/banner.png differ diff --git a/plugins/notification/build.rs b/plugins/notification/build.rs new file mode 100644 index 0000000..d7c0b3e --- /dev/null +++ b/plugins/notification/build.rs @@ -0,0 +1,36 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &[ + "notify", + "request_permission", + "is_permission_granted", + "register_action_types", + "register_listener", + "remove_listener", + "cancel", + "get_pending", + "remove_active", + "get_active", + "check_permissions", + "show", + "batch", + "list_channels", + "delete_channel", + "create_channel", + "permission_state", +]; + +fn main() { + let result = tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .try_build(); + + // when building documentation for Android the plugin build result is always Err() and is irrelevant to the crate documentation build + if !(cfg!(docsrs) && std::env::var("TARGET").unwrap().contains("android")) { + result.unwrap(); + } +} diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts new file mode 100644 index 0000000..534635f --- /dev/null +++ b/plugins/notification/guest-js/index.ts @@ -0,0 +1,967 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Send toast notifications (brief auto-expiring OS window element) to your user. + * Can also be used with the Notification Web API. + * + * @module + */ + +import { + invoke, + type PluginListener, + addPluginListener +} from '@tauri-apps/api/core' + +export type { PermissionState } from '@tauri-apps/api/core' + +/** + * Options to send a notification. + * + * @since 2.0.0 + */ +interface Options { + /** + * The notification identifier to reference this object later. Must be a 32-bit integer. + */ + id?: number + /** + * Identifier of the {@link Channel} that deliveres this notification. + * + * If the channel does not exist, the notification won't fire. + * Make sure the channel exists with {@link listChannels} and {@link createChannel}. + */ + channelId?: string + /** + * The title displayed on the notification. + */ + title: string + /** + * Optional notification body. + * */ + body?: string + /** + * Schedule this notification to fire on a later time or a fixed interval. + */ + schedule?: Schedule + /** + * Multiline text. + * Changes the notification style to big text. + * Cannot be used with `inboxLines`. + */ + largeBody?: string + /** + * Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + */ + summary?: string + /** + * Defines an action type for this notification. + */ + actionTypeId?: string + /** + * Identifier used to group multiple notifications. + * + * https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier + */ + group?: string + /** + * Instructs the system that this notification is the summary of a group on Android. + */ + groupSummary?: boolean + /** + * The sound resource name or file path for the notification. + * + * ## Platform-specific behavior: + * + * - On macOS: use system sounds (e.g., "Ping", "Blow") or sound files in the app bundle + * - On Linux: use XDG theme sounds (e.g., "message-new-instant") or file paths + * - On Windows: use file paths to sound files (.wav format) + * - On Mobile: use resource names + */ + sound?: string + /** + * List of lines to add to the notification. + * Changes the notification style to inbox. + * Cannot be used with `largeBody`. + * + * Only supports up to 5 lines. + */ + inboxLines?: string[] + /** + * Notification icon. + * + * On Android the icon must be placed in the app's `res/drawable` folder. + */ + icon?: string + /** + * Notification large icon (Android). + * + * The icon must be placed in the app's `res/drawable` folder. + */ + largeIcon?: string + /** + * Icon color on Android. + */ + iconColor?: string + /** + * Notification attachments. + */ + attachments?: Attachment[] + /** + * Extra payload to store in the notification. + */ + extra?: Record + /** + * If true, the notification cannot be dismissed by the user on Android. + * + * An application service must manage the dismissal of the notification. + * It is typically used to indicate a background task that is pending (e.g. a file download) + * or the user is engaged with (e.g. playing music). + */ + ongoing?: boolean + /** + * Automatically cancel the notification when the user clicks on it. + */ + autoCancel?: boolean + /** + * Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + */ + silent?: boolean + /** + * Notification visibility. + */ + visibility?: Visibility + /** + * Sets the number of items this notification represents on Android. + */ + number?: number +} + +/** + * The set of date fields a scheduled notification must match to be delivered. + * + * Fields that are not set match any value, so the notification fires on every date + * whose remaining components match. + */ +interface ScheduleInterval { + /** + * The year the notification fires on. + */ + year?: number + /** + * The month of the year the notification fires on. + */ + month?: number + /** + * The day of the month the notification fires on. + */ + day?: number + /** + * The day of the week the notification fires on. + * + * 1 - Sunday + * 2 - Monday + * 3 - Tuesday + * 4 - Wednesday + * 5 - Thursday + * 6 - Friday + * 7 - Saturday + */ + weekday?: number + /** + * The hour of the day the notification fires on, in the 24-hour clock. + */ + hour?: number + /** + * The minute of the hour the notification fires on. + */ + minute?: number + /** + * The second of the minute the notification fires on. + */ + second?: number +} + +/** + * The unit of the repeating interval used by {@link Schedule.every}. + */ +enum ScheduleEvery { + /** + * The notification repeats every year. + * + * On Android a year is approximated as 52 weeks. + */ + Year = 'year', + /** + * The notification repeats every month. + * + * On Android a month is approximated as 30 days. + */ + Month = 'month', + /** + * The notification repeats every two weeks. + */ + TwoWeeks = 'twoWeeks', + /** + * The notification repeats every week. + */ + Week = 'week', + /** + * The notification repeats every day. + */ + Day = 'day', + /** + * The notification repeats every hour. + */ + Hour = 'hour', + /** + * The notification repeats every minute. + */ + Minute = 'minute', + /** + * The notification repeats every second. + * + * Not supported on iOS, where repeating triggers must be at least a minute apart. + */ + Second = 'second' +} + +/** + * Defines when a scheduled notification is delivered. + * + * Build one with the static {@link Schedule.at}, {@link Schedule.interval} and + * {@link Schedule.every} helpers, then pass it to the `schedule` option of a notification. + * Scheduling is only supported on mobile; desktop notifications are always shown immediately. + * + * @since 2.0.0 + */ +class Schedule { + /** + * Set when the notification fires at a fixed date and time. + */ + at: + | { + date: Date + repeating: boolean + allowWhileIdle: boolean + } + | undefined + + /** + * Set when the notification fires whenever the current date matches the given fields. + */ + interval: + | { + interval: ScheduleInterval + allowWhileIdle: boolean + } + | undefined + + /** + * Set when the notification repeats on a fixed interval. + */ + every: + | { + interval: ScheduleEvery + count: number + allowWhileIdle: boolean + } + | undefined + + /** + * Creates a schedule that fires the notification at the given date and time. + * + * @example + * ```typescript + * import { Schedule, sendNotification } from '@tauri-apps/plugin-notification'; + * const schedule = Schedule.at(new Date(Date.now() + 60 * 1000)); + * sendNotification({ title: 'Tauri', body: 'One minute later', schedule }); + * ``` + * + * @param date The date and time the notification fires at. It must be in the future. + * @param repeating Whether the notification keeps repeating, using the duration between the moment it is scheduled and `date` as the interval. The interval must be at least one minute on iOS. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ + static at(date: Date, repeating = false, allowWhileIdle = false): Schedule { + return { + at: { date, repeating, allowWhileIdle }, + interval: undefined, + every: undefined + } + } + + /** + * Creates a schedule that fires the notification whenever the current date matches + * every field set on the given interval. + * + * @example + * ```typescript + * import { Schedule, sendNotification } from '@tauri-apps/plugin-notification'; + * // fires every day at 9:00 + * const schedule = Schedule.interval({ hour: 9, minute: 0 }); + * sendNotification({ title: 'Tauri', body: 'Good morning', schedule }); + * ``` + * + * @param interval The date fields the current date must match for the notification to fire. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ + static interval( + interval: ScheduleInterval, + allowWhileIdle = false + ): Schedule { + return { + at: undefined, + interval: { interval, allowWhileIdle }, + every: undefined + } + } + + /** + * Creates a schedule that repeatedly fires the notification, once every `count` interval units. + * + * @example + * ```typescript + * import { Schedule, ScheduleEvery, sendNotification } from '@tauri-apps/plugin-notification'; + * const schedule = Schedule.every(ScheduleEvery.Hour, 2); + * sendNotification({ title: 'Tauri', body: 'Every two hours', schedule }); + * ``` + * + * @param kind The unit of the repeating interval. + * @param count How many interval units elapse between each notification. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ + static every( + kind: ScheduleEvery, + count: number, + allowWhileIdle = false + ): Schedule { + return { + at: undefined, + interval: undefined, + every: { interval: kind, count, allowWhileIdle } + } + } +} + +/** + * Attachment of a notification. + */ +interface Attachment { + /** Attachment identifier. */ + id: string + /** Attachment URL. Accepts the `asset` and `file` protocols. */ + url: string +} + +/** + * A button the user can tap on a notification, belonging to an {@link ActionType}. + * + * Only used on mobile. On Android only the identifier, the title and the input flag are used. + */ +interface Action { + /** + * The identifier of this action, reported back when the user triggers it. + */ + id: string + /** + * The text displayed on the action button. + */ + title: string + /** + * Whether the device must be unlocked for the action to run. iOS only. + */ + requiresAuthentication?: boolean + /** + * Whether the app is brought to the foreground when the action is triggered. iOS only. + */ + foreground?: boolean + /** + * Whether the action is displayed as destructive, usually in red. iOS only. + */ + destructive?: boolean + /** + * Whether triggering the action lets the user type a text response. + */ + input?: boolean + /** + * The text displayed on the button that submits the text input. iOS only. + */ + inputButtonTitle?: string + /** + * The placeholder displayed on the empty text input field. iOS only. + */ + inputPlaceholder?: string +} + +/** + * A group of {@link Action}s a notification can display, referenced by the + * `actionTypeId` option of a notification. + * + * Register it with {@link registerActionTypes} before sending a notification that uses it. + * Only used on mobile. + */ +interface ActionType { + /** + * The identifier of this action type + */ + id: string + /** + * The list of associated actions + */ + actions: Action[] + /** + * The placeholder shown instead of the notification body when previews are hidden. iOS only. + */ + hiddenPreviewsBodyPlaceholder?: string + /** + * Whether the app is notified when the user dismisses the notification. iOS only. + */ + customDismissAction?: boolean + /** + * Whether the notification can be displayed in a CarPlay environment. iOS only. + */ + allowInCarPlay?: boolean + /** + * Whether the notification title is shown even when previews are hidden. iOS only. + */ + hiddenPreviewsShowTitle?: boolean + /** + * Whether the notification subtitle is shown even when previews are hidden. iOS only. + */ + hiddenPreviewsShowSubtitle?: boolean +} + +/** + * A notification that was scheduled and has not been delivered yet. + * + * Returned by {@link pending}, which is only supported on mobile. + */ +interface PendingNotification { + /** + * The identifier of the notification. + */ + id: number + /** + * The title of the notification, if it was set. + */ + title?: string + /** + * The body of the notification, if it was set. + */ + body?: string + /** + * The schedule that determines when the notification is delivered. + */ + schedule: Schedule +} + +/** + * A notification that was delivered and is still visible in the notification center. + * + * Returned by {@link active}, which is only supported on mobile. Which fields are + * populated depends on the platform, since Android and iOS expose different + * information about delivered notifications. + */ +interface ActiveNotification { + /** + * The identifier of the notification. + */ + id: number + /** + * The tag the notification was posted with. Android only. + */ + tag?: string + /** + * The title of the notification, if it was set. + */ + title?: string + /** + * The body of the notification, if it was set. + */ + body?: string + /** + * The identifier of the group the notification belongs to. Android only. + */ + group?: string + /** + * Whether the notification is the summary of its group. Android only. + */ + groupSummary: boolean + /** + * The platform extras attached to the notification, as string values. Android only. + */ + data: Record + /** + * The extra payload that was stored in the notification. + */ + extra: Record + /** + * The attachments of the notification. iOS only. + */ + attachments: Attachment[] + /** + * The identifier of the action type the notification was registered with. iOS only. + */ + actionTypeId?: string + /** + * The schedule the notification was delivered with, if it was scheduled. + */ + schedule?: Schedule + /** + * The sound resource name of the notification. iOS only. + */ + sound?: string +} + +/** + * How much the notifications of a {@link Channel} interrupt the user. + * + * It maps to the Android `NotificationManager.IMPORTANCE_*` constants and is only used on Android. + */ +enum Importance { + /** + * The notifications are not shown. + */ + None = 0, + /** + * The notifications are only shown in the shade, below the fold, without a status bar icon. + */ + Min, + /** + * The notifications are shown without a sound. + */ + Low, + /** + * The notifications are shown and make a sound. + * + * This is the value used when the channel does not define an importance. + */ + Default, + /** + * The notifications are shown, make a sound and pop up as a heads-up notification. + */ + High +} + +/** + * How much of a notification is shown on the lock screen. + * + * It maps to the Android `Notification.VISIBILITY_*` constants and is only used on Android. + */ +enum Visibility { + /** + * The notification is not shown on the lock screen at all. + */ + Secret = -1, + /** + * The notification is shown on the lock screen with its sensitive content hidden. + * + * This is the value used when the channel does not define a visibility. + */ + Private, + /** + * The notification is shown in full on the lock screen. + */ + Public +} + +/** + * A notification channel, the category users configure notification behavior on. + * + * Notifications reference a channel through their `channelId` option and are not delivered + * when the channel does not exist. Channels are only supported on Android. + */ +interface Channel { + /** + * The identifier of this channel. + */ + id: string + /** + * The user visible name of this channel. + */ + name: string + /** + * The user visible description of this channel. + */ + description?: string + /** + * The name of the sound resource played by the notifications of this channel. + * + * The resource must be placed in the app's `res/raw` folder. + */ + sound?: string + /** + * Whether the notifications of this channel blink the device light. + */ + lights?: boolean + /** + * The color of the device light, as a color string such as `#ff0000`. + */ + lightColor?: string + /** + * Whether the notifications of this channel vibrate the device. + */ + vibration?: boolean + /** + * How much the notifications of this channel interrupt the user. + */ + importance?: Importance + /** + * How much of the notifications of this channel is shown on the lock screen. + */ + visibility?: Visibility +} + +/** + * Checks if the permission to send notifications is granted. + * @example + * ```typescript + * import { isPermissionGranted } from '@tauri-apps/plugin-notification'; + * const permissionGranted = await isPermissionGranted(); + * ``` + * + * @returns A promise resolving to whether the permission to send notifications is granted. + * + * @since 2.0.0 + */ +async function isPermissionGranted(): Promise { + if (window.Notification.permission !== 'default') { + return await Promise.resolve(window.Notification.permission === 'granted') + } + return await invoke('plugin:notification|is_permission_granted') +} + +/** + * Requests the permission to send notifications. + * @example + * ```typescript + * import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'; + * let permissionGranted = await isPermissionGranted(); + * if (!permissionGranted) { + * const permission = await requestPermission(); + * permissionGranted = permission === 'granted'; + * } + * ``` + * + * @returns A promise resolving to whether the user granted the permission or not. + * + * @since 2.0.0 + */ +async function requestPermission(): Promise { + return await window.Notification.requestPermission() +} + +/** + * Sends a notification to the user. + * @example + * ```typescript + * import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; + * let permissionGranted = await isPermissionGranted(); + * if (!permissionGranted) { + * const permission = await requestPermission(); + * permissionGranted = permission === 'granted'; + * } + * if (permissionGranted) { + * sendNotification('Tauri is awesome!'); + * sendNotification({ title: 'TAURI', body: 'Tauri is awesome!' }); + * } + * ``` + * + * @param options The notification content, or the notification title when a string is given. + * + * @since 2.0.0 + */ +function sendNotification(options: Options | string): void { + if (typeof options === 'string') { + new window.Notification(options) + } else { + new window.Notification(options.title, options) + } +} + +/** + * Register actions that are performed when the user clicks on the notification. + * + * @example + * ```typescript + * import { registerActionTypes } from '@tauri-apps/plugin-notification'; + * await registerActionTypes([{ + * id: 'tauri', + * actions: [{ + * id: 'my-action', + * title: 'Settings' + * }] + * }]) + * ``` + * + * @param types The action types to register. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function registerActionTypes(types: ActionType[]): Promise { + await invoke('plugin:notification|register_action_types', { types }) +} + +/** + * Retrieves the list of pending notifications. + * + * @example + * ```typescript + * import { pending } from '@tauri-apps/plugin-notification'; + * const pendingNotifications = await pending(); + * ``` + * + * @returns A promise resolving to the list of pending notifications. + * + * @since 2.0.0 + */ +async function pending(): Promise { + return await invoke('plugin:notification|get_pending') +} + +/** + * Cancels the pending notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/plugin-notification'; + * await cancel([-34234, 23432, 4311]); + * ``` + * + * @param notifications The identifiers of the pending notifications to cancel. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancel(notifications: number[]): Promise { + await invoke('plugin:notification|cancel', { notifications }) +} + +/** + * Cancels all pending notifications. + * + * @example + * ```typescript + * import { cancelAll } from '@tauri-apps/plugin-notification'; + * await cancelAll(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancelAll(): Promise { + await invoke('plugin:notification|cancel') +} + +/** + * Retrieves the list of active notifications. + * + * @example + * ```typescript + * import { active } from '@tauri-apps/plugin-notification'; + * const activeNotifications = await active(); + * ``` + * + * @returns A promise resolving to the list of active notifications. + * + * @since 2.0.0 + */ +async function active(): Promise { + return await invoke('plugin:notification|get_active') +} + +/** + * Removes the active notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { removeActive } from '@tauri-apps/plugin-notification'; + * await removeActive([{ id: -34234 }, { id: 23432 }, { id: 4311 }]) + * ``` + * + * @param notifications The active notifications to remove, identified by their id and, on Android, their optional tag. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeActive( + notifications: Array<{ id: number; tag?: string }> +): Promise { + await invoke('plugin:notification|remove_active', { notifications }) +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { removeAllActive } from '@tauri-apps/plugin-notification'; + * await removeAllActive() + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeAllActive(): Promise { + await invoke('plugin:notification|remove_active') +} + +/** + * Creates a notification channel. + * + * @example + * ```typescript + * import { createChannel, Importance, Visibility } from '@tauri-apps/plugin-notification'; + * await createChannel({ + * id: 'new-messages', + * name: 'New Messages', + * lights: true, + * vibration: true, + * importance: Importance.Default, + * visibility: Visibility.Private + * }); + * ``` + * + * @param channel The channel to create. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function createChannel(channel: Channel): Promise { + await invoke('plugin:notification|create_channel', { ...channel }) +} + +/** + * Removes the channel with the given identifier. + * + * @example + * ```typescript + * import { removeChannel } from '@tauri-apps/plugin-notification'; + * await removeChannel('new-messages'); + * ``` + * + * @param id The identifier of the channel to remove. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeChannel(id: string): Promise { + await invoke('plugin:notification|delete_channel', { id }) +} + +/** + * Retrieves the list of notification channels. + * + * @example + * ```typescript + * import { channels } from '@tauri-apps/plugin-notification'; + * const notificationChannels = await channels(); + * ``` + * + * @returns A promise resolving to the list of notification channels. + * + * @since 2.0.0 + */ +async function channels(): Promise { + return await invoke('plugin:notification|list_channels') +} + +/** + * Listens to notifications that are delivered while the app is running. + * + * Only emitted on mobile. + * + * @example + * ```typescript + * import { onNotificationReceived } from '@tauri-apps/plugin-notification'; + * const unlisten = await onNotificationReceived((notification) => { + * console.log(`received notification: ${notification.title}`); + * }); + * ``` + * + * @param cb The closure called with the notification that was delivered. + * + * @returns A promise resolving to a listener that can be used to stop listening for the event. + * + * @since 2.0.0 + */ +async function onNotificationReceived( + cb: (notification: Options) => void +): Promise { + return await addPluginListener('notification', 'notification', cb) +} + +/** + * Listens to the actions the user performs on a notification. + * + * Only emitted on mobile, for notifications that reference an action type + * registered with {@link registerActionTypes}. + * + * @example + * ```typescript + * import { onAction } from '@tauri-apps/plugin-notification'; + * const unlisten = await onAction((notification) => { + * console.log(`user acted on notification: ${notification.title}`); + * }); + * ``` + * + * @param cb The closure called with the notification the action was performed on. + * + * @returns A promise resolving to a listener that can be used to stop listening for the event. + * + * @since 2.0.0 + */ +async function onAction( + cb: (notification: Options) => void +): Promise { + return await addPluginListener('notification', 'actionPerformed', cb) +} + +export type { + Attachment, + Options, + Action, + ActionType, + PendingNotification, + ActiveNotification, + Channel, + ScheduleInterval +} + +export { + Importance, + Visibility, + sendNotification, + requestPermission, + isPermissionGranted, + registerActionTypes, + pending, + cancel, + cancelAll, + active, + removeActive, + removeAllActive, + createChannel, + removeChannel, + channels, + onNotificationReceived, + onAction, + Schedule, + ScheduleEvery +} diff --git a/plugins/notification/guest-js/init.ts b/plugins/notification/guest-js/init.ts new file mode 100644 index 0000000..3d1780a --- /dev/null +++ b/plugins/notification/guest-js/init.ts @@ -0,0 +1,107 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Webview script injected by the notification plugin. + * + * It replaces `window.Notification` with an implementation backed by the plugin, so the + * standard Notification Web API sends OS notifications, and keeps + * `window.Notification.permission` and `window.Notification.requestPermission` in sync + * with the permission state reported by the plugin. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' +import type { PermissionState } from '@tauri-apps/api/core' +import type { Options } from './index' +;(function () { + let permissionSettable = false + let permissionValue = 'default' + + async function isPermissionGranted(): Promise { + if (window.Notification.permission !== 'default') { + return await Promise.resolve(window.Notification.permission === 'granted') + } + // Windows always grants the permission, and asking the backend for it from + // the init script makes the WebView throw a STATUS_ACCESS_VIOLATION on + // remote websites, so answer it here instead of invoking. + // @ts-expect-error __TEMPLATE_windows__ will be replaced in rust before it's injected. + if (__TEMPLATE_windows__) { + return true + } + return await invoke('plugin:notification|is_permission_granted') + } + + function setNotificationPermission(value: NotificationPermission): void { + permissionSettable = true + // @ts-expect-error we can actually set this value on the webview + window.Notification.permission = value + permissionSettable = false + } + + async function requestPermission(): Promise { + return await invoke( + 'plugin:notification|request_permission' + ).then((permission) => { + setNotificationPermission( + permission === 'prompt' || permission === 'prompt-with-rationale' + ? 'default' + : permission + ) + return permission + }) + } + + async function sendNotification(options: string | Options): Promise { + if (typeof options === 'object') { + Object.freeze(options) + } + + await invoke('plugin:notification|notify', { + options: + typeof options === 'string' + ? { + title: options + } + : options + }) + } + + // @ts-expect-error unfortunately we can't implement the whole type, so we overwrite it with our own version + window.Notification = function (title, options) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const opts = options || {} + void sendNotification( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + Object.assign(opts, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + title + }) + ) + } + + // @ts-expect-error tauri does not have sync IPC :( + window.Notification.requestPermission = requestPermission + + Object.defineProperty(window.Notification, 'permission', { + enumerable: true, + get: () => permissionValue, + set: (v) => { + if (!permissionSettable) { + throw new Error('Readonly property') + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + permissionValue = v + } + }) + + void isPermissionGranted().then(function (response) { + if (response === null) { + setNotificationPermission('default') + } else { + setNotificationPermission(response ? 'granted' : 'denied') + } + }) +})() diff --git a/plugins/notification/ios/.gitignore b/plugins/notification/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/plugins/notification/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/plugins/notification/ios/Package.swift b/plugins/notification/ios/Package.swift new file mode 100644 index 0000000..bbd6df9 --- /dev/null +++ b/plugins/notification/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.5 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-notification", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-notification", + type: .static, + targets: ["tauri-plugin-notification"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-notification", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/notification/ios/README.md b/plugins/notification/ios/README.md new file mode 100644 index 0000000..f4900bd --- /dev/null +++ b/plugins/notification/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin {{ plugin_name_original }} + +A description of this package. diff --git a/plugins/notification/ios/Sources/Notification.swift b/plugins/notification/ios/Sources/Notification.swift new file mode 100644 index 0000000..259399b --- /dev/null +++ b/plugins/notification/ios/Sources/Notification.swift @@ -0,0 +1,286 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Tauri +import UserNotifications + +enum NotificationError: LocalizedError { + case triggerRepeatIntervalTooShort + case attachmentFileNotFound(path: String) + case attachmentUnableToCreate(String) + case pastScheduledTime + case invalidDate(String) + + var errorDescription: String? { + switch self { + case .triggerRepeatIntervalTooShort: + return "Schedule interval too short, must be a least 1 minute" + case .attachmentFileNotFound(let path): + return "Unable to find file \(path) for attachment" + case .attachmentUnableToCreate(let error): + return "Failed to create attachment: \(error)" + case .pastScheduledTime: + return "Scheduled time must be *after* current time" + case .invalidDate(let date): + return "Could not parse date \(date)" + } + } +} + +func makeNotificationContent(_ notification: Notification) throws -> UNNotificationContent { + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString( + forKey: notification.title, arguments: nil) + if let body = notification.body { + content.body = NSString.localizedUserNotificationString( + forKey: body, + arguments: nil) + } + + var userInfo: [String: Any] = [:] + + if let extra = notification.extra { + userInfo["__EXTRA__"] = extra + } + + if let schedule = notification.schedule { + userInfo["__SCHEDULE__"] = scheduleToDictionary(schedule) + } + + content.userInfo = userInfo + + if let actionTypeId = notification.actionTypeId { + content.categoryIdentifier = actionTypeId + } + + if let threadIdentifier = notification.group { + content.threadIdentifier = threadIdentifier + } + + if let summaryArgument = notification.summary { + content.summaryArgument = summaryArgument + } + + if let sound = notification.sound { + content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) + } + + if let attachments = notification.attachments { + content.attachments = try makeAttachments(attachments) + } + + return content +} + +func scheduleToDictionary(_ schedule: NotificationSchedule) -> [String: Any] { + switch schedule { + case .at(let date, let repeating): + return [ + "type": "at", + "date": date, + "repeating": repeating + ] + case .interval(let interval): + return [ + "type": "interval", + "interval": scheduleIntervalToDictionary(interval) + ] + case .every(let interval, let count): + return [ + "type": "every", + "interval": interval.rawValue, + "count": count + ] + } +} + +func scheduleIntervalToDictionary(_ interval: ScheduleInterval) -> [String: Any] { + var dict: [String: Any] = [:] + + if let year = interval.year { + dict["year"] = year + } + if let month = interval.month { + dict["month"] = month + } + if let day = interval.day { + dict["day"] = day + } + if let weekday = interval.weekday { + dict["weekday"] = weekday + } + if let hour = interval.hour { + dict["hour"] = hour + } + if let minute = interval.minute { + dict["minute"] = minute + } + if let second = interval.second { + dict["second"] = second + } + + return dict +} + +func makeAttachments(_ attachments: [NotificationAttachment]) throws -> [UNNotificationAttachment] { + var createdAttachments = [UNNotificationAttachment]() + + for attachment in attachments { + + guard let urlObject = makeAttachmentUrl(attachment.url) else { + throw NotificationError.attachmentFileNotFound(path: attachment.url) + } + + let options = attachment.options != nil ? makeAttachmentOptions(attachment.options!) : nil + + do { + let newAttachment = try UNNotificationAttachment( + identifier: attachment.id, url: urlObject, options: options) + createdAttachments.append(newAttachment) + } catch { + throw NotificationError.attachmentUnableToCreate(error.localizedDescription) + } + } + + return createdAttachments +} + +func makeAttachmentUrl(_ path: String) -> URL? { + return URL(string: path) +} + +func makeAttachmentOptions(_ options: NotificationAttachmentOptions) -> [AnyHashable: Any] { + var opts: [AnyHashable: Any] = [:] + + if let value = options.iosUNNotificationAttachmentOptionsTypeHintKey { + opts[UNNotificationAttachmentOptionsTypeHintKey] = value + } + if let value = options.iosUNNotificationAttachmentOptionsThumbnailHiddenKey { + opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] = value + } + if let value = options.iosUNNotificationAttachmentOptionsThumbnailClippingRectKey { + opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = value + } + if let value = options + .iosUNNotificationAttachmentOptionsThumbnailTimeKey + + { + opts[UNNotificationAttachmentOptionsThumbnailTimeKey] = value + } + return opts +} + +func handleScheduledNotification(_ schedule: NotificationSchedule) throws + -> UNNotificationTrigger? +{ + switch schedule { + case .at(let date, let repeating): + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + if let at = dateFormatter.date(from: date) { + let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) + + if dateInfo.date! < Date() { + throw NotificationError.pastScheduledTime + } + + let dateInterval = DateInterval(start: Date(), end: dateInfo.date!) + + // Notifications that repeat have to be at least a minute between each other + if repeating && dateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: dateInterval.duration, repeats: repeating) + + } else { + throw NotificationError.invalidDate(date) + } + case .interval(let interval): + let dateComponents = getDateComponents(interval) + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + case .every(let interval, let count): + if let repeatDateInterval = getRepeatDateInterval(interval, count) { + // Notifications that repeat have to be at least a minute between each other + if repeatDateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: repeatDateInterval.duration, repeats: true) + } + } + + return nil +} + +/// Given our schedule format, return a DateComponents object +/// that only contains the components passed in. + +func getDateComponents(_ at: ScheduleInterval) -> DateComponents { + // var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date()) + // dateInfo.calendar = Calendar.current + var dateInfo = DateComponents() + + if let year = at.year { + dateInfo.year = year + } + if let month = at.month { + dateInfo.month = month + } + if let day = at.day { + dateInfo.day = day + } + if let hour = at.hour { + dateInfo.hour = hour + } + if let minute = at.minute { + dateInfo.minute = minute + } + if let second = at.second { + dateInfo.second = second + } + if let weekday = at.weekday { + dateInfo.weekday = weekday + } + return dateInfo +} + +/// Compute the difference between the string representation of a date +/// interval and today. For example, if every is "month", then we +/// return the interval between today and a month from today. + +func getRepeatDateInterval(_ every: ScheduleEveryKind, _ count: Int) -> DateInterval? { + let cal = Calendar.current + let now = Date() + switch every { + case .year: + let newDate = cal.date(byAdding: .year, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .month: + let newDate = cal.date(byAdding: .month, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .twoWeeks: + let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)! + return DateInterval(start: now, end: newDate) + case .week: + let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .day: + let newDate = cal.date(byAdding: .day, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .hour: + let newDate = cal.date(byAdding: .hour, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .minute: + let newDate = cal.date(byAdding: .minute, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .second: + let newDate = cal.date(byAdding: .second, value: count, to: now)! + return DateInterval(start: now, end: newDate) + } +} diff --git a/plugins/notification/ios/Sources/NotificationCategory.swift b/plugins/notification/ios/Sources/NotificationCategory.swift new file mode 100644 index 0000000..f796e03 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationCategory.swift @@ -0,0 +1,97 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Tauri +import UserNotifications + +internal func makeCategories(_ actionTypes: [ActionType]) { + var createdCategories = [UNNotificationCategory]() + + let generalCategory = UNNotificationCategory( + identifier: "GENERAL", + actions: [], + intentIdentifiers: [], + options: .customDismissAction) + + createdCategories.append(generalCategory) + for type in actionTypes { + let newActions = makeActions(type.actions) + + // Create the custom actions for the TIMER_EXPIRED category. + var newCategory: UNNotificationCategory? + + newCategory = UNNotificationCategory( + identifier: type.id, + actions: newActions, + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: type.hiddenBodyPlaceholder ?? "", + options: makeCategoryOptions(type)) + + createdCategories.append(newCategory!) + } + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories(Set(createdCategories)) +} + +func makeActions(_ actions: [Action]) -> [UNNotificationAction] { + var createdActions = [UNNotificationAction]() + + for action in actions { + var newAction: UNNotificationAction + if action.input ?? false { + if action.inputButtonTitle != nil { + newAction = UNTextInputNotificationAction( + identifier: action.id, + title: action.title, + options: makeActionOptions(action), + textInputButtonTitle: action.inputButtonTitle ?? "", + textInputPlaceholder: action.inputPlaceholder ?? "") + } else { + newAction = UNTextInputNotificationAction( + identifier: action.id, title: action.title, options: makeActionOptions(action)) + } + } else { + // Create the custom actions for the TIMER_EXPIRED category. + newAction = UNNotificationAction( + identifier: action.id, + title: action.title, + options: makeActionOptions(action)) + } + createdActions.append(newAction) + } + + return createdActions +} + +func makeActionOptions(_ action: Action) -> UNNotificationActionOptions { + if action.foreground ?? false { + return .foreground + } + if action.destructive ?? false { + return .destructive + } + if action.requiresAuthentication ?? false { + return .authenticationRequired + } + return UNNotificationActionOptions(rawValue: 0) +} + +func makeCategoryOptions(_ type: ActionType) -> UNNotificationCategoryOptions { + if type.customDismissAction ?? false { + return .customDismissAction + } + if type.allowInCarPlay ?? false { + return .allowInCarPlay + } + + if type.hiddenPreviewsShowTitle ?? false { + return .hiddenPreviewsShowTitle + } + if type.hiddenPreviewsShowSubtitle ?? false { + return .hiddenPreviewsShowSubtitle + } + + return UNNotificationCategoryOptions(rawValue: 0) +} diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift new file mode 100644 index 0000000..1bf134b --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -0,0 +1,118 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Tauri +import UserNotifications + +public class NotificationHandler: NSObject, NotificationHandlerProtocol { + + public weak var plugin: Plugin? + + private var notificationsMap = [String: Notification]() + + internal func saveNotification(_ key: String, _ notification: Notification) { + notificationsMap.updateValue(notification, forKey: key) + } + + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in + completion?(granted, error) + } + } + + public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + completion?(settings.authorizationStatus) + } + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + let notificationData = toActiveNotification(notification.request) + try? self.plugin?.trigger("notification", data: notificationData) + + if let options = notificationsMap[notification.request.identifier] { + if options.silent ?? false { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + } + + return [ + .badge, + .sound, + .alert, + ] + } + + public func didReceive(response: UNNotificationResponse) { + let originalNotificationRequest = response.notification.request + let actionId = response.actionIdentifier + + var actionIdValue: String + // We turn the two default actions (open/dismiss) into generic strings + if actionId == UNNotificationDefaultActionIdentifier { + actionIdValue = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + actionIdValue = "dismiss" + } else { + actionIdValue = actionId + } + + var inputValue: String? = nil + // If the type of action was for an input type, get the value + if let inputType = response as? UNTextInputNotificationResponse { + inputValue = inputType.userText + } + + try? self.plugin?.trigger( + "actionPerformed", + data: ReceivedNotification( + actionId: actionIdValue, + inputValue: inputValue, + notification: toActiveNotification(originalNotificationRequest) + )) + } + + func toActiveNotification(_ request: UNNotificationRequest) -> ActiveNotification { + let notificationRequest = notificationsMap[request.identifier]! + return ActiveNotification( + id: Int(request.identifier) ?? -1, + title: request.content.title, + body: request.content.body, + sound: notificationRequest.sound ?? "", + actionTypeId: request.content.categoryIdentifier, + attachments: notificationRequest.attachments + ) + } + + func toPendingNotification(_ request: UNNotificationRequest) -> PendingNotification { + return PendingNotification( + id: Int(request.identifier) ?? -1, + title: request.content.title, + body: request.content.body + ) + } +} + +struct PendingNotification: Encodable { + let id: Int + let title: String + let body: String +} + +struct ActiveNotification: Encodable { + let id: Int + let title: String + let body: String + let sound: String + let actionTypeId: String + let attachments: [NotificationAttachment]? +} + +struct ReceivedNotification: Encodable { + let actionId: String + let inputValue: String? + let notification: ActiveNotification +} diff --git a/plugins/notification/ios/Sources/NotificationManager.swift b/plugins/notification/ios/Sources/NotificationManager.swift new file mode 100644 index 0000000..81a585b --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationManager.swift @@ -0,0 +1,47 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation +import UserNotifications + +@objc public protocol NotificationHandlerProtocol { + func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions + func didReceive(response: UNNotificationResponse) +} + +@objc public class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + public weak var notificationHandler: NotificationHandlerProtocol? + + override init() { + super.init() + let center = UNUserNotificationCenter.current() + center.delegate = self + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var presentationOptions: UNNotificationPresentationOptions? = nil + + if notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + presentationOptions = notificationHandler?.willPresent(notification: notification) + } + + completionHandler(presentationOptions ?? []) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + notificationHandler?.didReceive(response: response) + } + + completionHandler() + } +} diff --git a/plugins/notification/ios/Sources/NotificationPlugin.swift b/plugins/notification/ios/Sources/NotificationPlugin.swift new file mode 100644 index 0000000..8adbff9 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -0,0 +1,290 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import SwiftRs +import Tauri +import UIKit +import UserNotifications +import WebKit + +enum ShowNotificationError: LocalizedError { + case make(Error) + case create(Error) + + var errorDescription: String? { + switch self { + case .make(let error): + return "Unable to make notification: \(error)" + case .create(let error): + return "Unable to create notification: \(error)" + } + } +} + +enum ScheduleEveryKind: String, Decodable { + case year + case month + case twoWeeks + case week + case day + case hour + case minute + case second +} + +struct ScheduleInterval: Decodable { + var year: Int? + var month: Int? + var day: Int? + var weekday: Int? + var hour: Int? + var minute: Int? + var second: Int? +} + +enum NotificationSchedule: Decodable { + case at(date: String, repeating: Bool) + case interval(interval: ScheduleInterval) + case every(interval: ScheduleEveryKind, count: Int) +} + +struct NotificationAttachmentOptions: Codable { + let iosUNNotificationAttachmentOptionsTypeHintKey: String? + let iosUNNotificationAttachmentOptionsThumbnailHiddenKey: String? + let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey: String? + let iosUNNotificationAttachmentOptionsThumbnailTimeKey: String? +} + +struct NotificationAttachment: Codable { + let id: String + let url: String + let options: NotificationAttachmentOptions? +} + +struct Notification: Decodable { + let id: Int + var title: String + var body: String? + var extra: [String: String]? + var schedule: NotificationSchedule? + var attachments: [NotificationAttachment]? + var sound: String? + var group: String? + var actionTypeId: String? + var summary: String? + var silent: Bool? +} + +struct RemoveActiveNotification: Decodable { + let id: Int +} + +struct RemoveActiveArgs: Decodable { + let notifications: [RemoveActiveNotification] +} + +func showNotification(invoke: Invoke, notification: Notification) + throws -> UNNotificationRequest +{ + var content: UNNotificationContent + do { + content = try makeNotificationContent(notification) + } catch { + throw ShowNotificationError.make(error) + } + + var trigger: UNNotificationTrigger? + + do { + if let schedule = notification.schedule { + try trigger = handleScheduledNotification(schedule) + } + } catch { + throw ShowNotificationError.create(error) + } + + // Schedule the request. + let request = UNNotificationRequest( + identifier: "\(notification.id)", content: content, trigger: trigger + ) + + let center = UNUserNotificationCenter.current() + center.add(request) { (error: Error?) in + if let theError = error { + invoke.reject(theError.localizedDescription) + } + } + + return request +} + +struct CancelArgs: Decodable { + // `cancelAll()` sends no list: cancel every pending notification + let notifications: [Int]? +} + +struct Action: Decodable { + let id: String + let title: String + var requiresAuthentication: Bool? + var foreground: Bool? + var destructive: Bool? + var input: Bool? + var inputButtonTitle: String? + var inputPlaceholder: String? +} + +struct ActionType: Decodable { + let id: String + let actions: [Action] + var hiddenPreviewsBodyPlaceholder: String? + var customDismissAction: Bool? + var allowInCarPlay: Bool? + var hiddenPreviewsShowTitle: Bool? + var hiddenPreviewsShowSubtitle: Bool? + var hiddenBodyPlaceholder: String? +} + +struct RegisterActionTypesArgs: Decodable { + let types: [ActionType] +} + +struct BatchArgs: Decodable { + let notifications: [Notification] +} + +class NotificationPlugin: Plugin { + let notificationHandler = NotificationHandler() + let notificationManager = NotificationManager() + + override init() { + super.init() + notificationManager.notificationHandler = notificationHandler + notificationHandler.plugin = self + } + + @objc public func show(_ invoke: Invoke) throws { + let notification = try invoke.parseArgs(Notification.self) + + let request = try showNotification(invoke: invoke, notification: notification) + notificationHandler.saveNotification(request.identifier, notification) + invoke.resolve(Int(request.identifier) ?? -1) + } + + @objc public func batch(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(BatchArgs.self) + var ids = [Int]() + + for notification in args.notifications { + let request = try showNotification(invoke: invoke, notification: notification) + notificationHandler.saveNotification(request.identifier, notification) + ids.append(Int(request.identifier) ?? -1) + } + + invoke.resolve(ids) + } + + @objc public override func requestPermissions(_ invoke: Invoke) { + notificationHandler.requestPermissions { granted, error in + guard error == nil else { + invoke.reject(error!.localizedDescription) + return + } + invoke.resolve(["permissionState": granted ? "granted" : "denied"]) + } + } + + @objc public override func checkPermissions(_ invoke: Invoke) { + notificationHandler.checkPermissions { status in + let permission: String + + switch status { + case .authorized, .ephemeral, .provisional: + permission = "granted" + case .denied: + permission = "denied" + case .notDetermined: + permission = "prompt" + @unknown default: + permission = "prompt" + } + + invoke.resolve(["permissionState": permission]) + } + } + + @objc func cancel(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(CancelArgs.self) + + if let notifications = args.notifications { + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: notifications.map { String($0) } + ) + } else { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + invoke.resolve() + } + + @objc func getPending(_ invoke: Invoke) { + UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: { + (notifications) in + let ret = notifications.compactMap({ [weak self] (notification) -> PendingNotification? in + return self?.notificationHandler.toPendingNotification(notification) + }) + + invoke.resolve(ret) + }) + } + + @objc func registerActionTypes(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(RegisterActionTypesArgs.self) + makeCategories(args.types) + invoke.resolve() + } + + @objc func removeActive(_ invoke: Invoke) { + do { + let args = try invoke.parseArgs(RemoveActiveArgs.self) + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: args.notifications.map { String($0.id) }) + invoke.resolve() + } catch { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + DispatchQueue.main.async(execute: { + UIApplication.shared.applicationIconBadgeNumber = 0 + }) + invoke.resolve() + } + } + + @objc func getActive(_ invoke: Invoke) { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { + (notifications) in + let ret = notifications.map({ (notification) -> ActiveNotification in + return self.notificationHandler.toActiveNotification( + notification.request) + }) + invoke.resolve(ret) + }) + } + + @objc func createChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func deleteChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func listChannels(_ invoke: Invoke) { + invoke.reject("not implemented") + } + +} + +@_cdecl("init_plugin_notification") +func initPlugin() -> Plugin { + return NotificationPlugin() +} diff --git a/plugins/notification/ios/Tests/PluginTests/PluginTests.swift b/plugins/notification/ios/Tests/PluginTests/PluginTests.swift new file mode 100644 index 0000000..99992ce --- /dev/null +++ b/plugins/notification/ios/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,12 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import XCTest +@testable import ExamplePlugin + +final class ExamplePluginTests: XCTestCase { + func testExample() throws { + let plugin = ExamplePlugin() + } +} diff --git a/plugins/notification/package.json b/plugins/notification/package.json new file mode 100644 index 0000000..58d64a0 --- /dev/null +++ b/plugins/notification/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-notification", + "version": "2.4.0", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/notification/permissions/autogenerated/commands/batch.toml b/plugins/notification/permissions/autogenerated/commands/batch.toml new file mode 100644 index 0000000..c52cc16 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/batch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-batch" +description = "Enables the batch command without any pre-configured scope." +commands.allow = ["batch"] + +[[permission]] +identifier = "deny-batch" +description = "Denies the batch command without any pre-configured scope." +commands.deny = ["batch"] diff --git a/plugins/notification/permissions/autogenerated/commands/cancel.toml b/plugins/notification/permissions/autogenerated/commands/cancel.toml new file mode 100644 index 0000000..91efeaa --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/cancel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cancel" +description = "Enables the cancel command without any pre-configured scope." +commands.allow = ["cancel"] + +[[permission]] +identifier = "deny-cancel" +description = "Denies the cancel command without any pre-configured scope." +commands.deny = ["cancel"] diff --git a/plugins/notification/permissions/autogenerated/commands/check_permissions.toml b/plugins/notification/permissions/autogenerated/commands/check_permissions.toml new file mode 100644 index 0000000..f5af08b --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/check_permissions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-check-permissions" +description = "Enables the check_permissions command without any pre-configured scope." +commands.allow = ["check_permissions"] + +[[permission]] +identifier = "deny-check-permissions" +description = "Denies the check_permissions command without any pre-configured scope." +commands.deny = ["check_permissions"] diff --git a/plugins/notification/permissions/autogenerated/commands/create_channel.toml b/plugins/notification/permissions/autogenerated/commands/create_channel.toml new file mode 100644 index 0000000..2c93147 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/create_channel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create-channel" +description = "Enables the create_channel command without any pre-configured scope." +commands.allow = ["create_channel"] + +[[permission]] +identifier = "deny-create-channel" +description = "Denies the create_channel command without any pre-configured scope." +commands.deny = ["create_channel"] diff --git a/plugins/notification/permissions/autogenerated/commands/delete_channel.toml b/plugins/notification/permissions/autogenerated/commands/delete_channel.toml new file mode 100644 index 0000000..0adaf2b --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/delete_channel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-delete-channel" +description = "Enables the delete_channel command without any pre-configured scope." +commands.allow = ["delete_channel"] + +[[permission]] +identifier = "deny-delete-channel" +description = "Denies the delete_channel command without any pre-configured scope." +commands.deny = ["delete_channel"] diff --git a/plugins/notification/permissions/autogenerated/commands/get_active.toml b/plugins/notification/permissions/autogenerated/commands/get_active.toml new file mode 100644 index 0000000..b841eb8 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/get_active.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-active" +description = "Enables the get_active command without any pre-configured scope." +commands.allow = ["get_active"] + +[[permission]] +identifier = "deny-get-active" +description = "Denies the get_active command without any pre-configured scope." +commands.deny = ["get_active"] diff --git a/plugins/notification/permissions/autogenerated/commands/get_pending.toml b/plugins/notification/permissions/autogenerated/commands/get_pending.toml new file mode 100644 index 0000000..f3bae7a --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/get_pending.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-pending" +description = "Enables the get_pending command without any pre-configured scope." +commands.allow = ["get_pending"] + +[[permission]] +identifier = "deny-get-pending" +description = "Denies the get_pending command without any pre-configured scope." +commands.deny = ["get_pending"] diff --git a/plugins/notification/permissions/autogenerated/commands/is_permission_granted.toml b/plugins/notification/permissions/autogenerated/commands/is_permission_granted.toml new file mode 100644 index 0000000..5faa73f --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/is_permission_granted.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-permission-granted" +description = "Enables the is_permission_granted command without any pre-configured scope." +commands.allow = ["is_permission_granted"] + +[[permission]] +identifier = "deny-is-permission-granted" +description = "Denies the is_permission_granted command without any pre-configured scope." +commands.deny = ["is_permission_granted"] diff --git a/plugins/notification/permissions/autogenerated/commands/list_channels.toml b/plugins/notification/permissions/autogenerated/commands/list_channels.toml new file mode 100644 index 0000000..cb20cd5 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/list_channels.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-list-channels" +description = "Enables the list_channels command without any pre-configured scope." +commands.allow = ["list_channels"] + +[[permission]] +identifier = "deny-list-channels" +description = "Denies the list_channels command without any pre-configured scope." +commands.deny = ["list_channels"] diff --git a/plugins/notification/permissions/autogenerated/commands/notify.toml b/plugins/notification/permissions/autogenerated/commands/notify.toml new file mode 100644 index 0000000..7d6d46c --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/notify.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-notify" +description = "Enables the notify command without any pre-configured scope." +commands.allow = ["notify"] + +[[permission]] +identifier = "deny-notify" +description = "Denies the notify command without any pre-configured scope." +commands.deny = ["notify"] diff --git a/plugins/notification/permissions/autogenerated/commands/permission_state.toml b/plugins/notification/permissions/autogenerated/commands/permission_state.toml new file mode 100644 index 0000000..dddcd86 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/permission_state.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-permission-state" +description = "Enables the permission_state command without any pre-configured scope." +commands.allow = ["permission_state"] + +[[permission]] +identifier = "deny-permission-state" +description = "Denies the permission_state command without any pre-configured scope." +commands.deny = ["permission_state"] diff --git a/plugins/notification/permissions/autogenerated/commands/register_action_types.toml b/plugins/notification/permissions/autogenerated/commands/register_action_types.toml new file mode 100644 index 0000000..cb5aa89 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/register_action_types.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register-action-types" +description = "Enables the register_action_types command without any pre-configured scope." +commands.allow = ["register_action_types"] + +[[permission]] +identifier = "deny-register-action-types" +description = "Denies the register_action_types command without any pre-configured scope." +commands.deny = ["register_action_types"] diff --git a/plugins/notification/permissions/autogenerated/commands/register_listener.toml b/plugins/notification/permissions/autogenerated/commands/register_listener.toml new file mode 100644 index 0000000..48363c0 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/register_listener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register-listener" +description = "Enables the register_listener command without any pre-configured scope." +commands.allow = ["register_listener"] + +[[permission]] +identifier = "deny-register-listener" +description = "Denies the register_listener command without any pre-configured scope." +commands.deny = ["register_listener"] diff --git a/plugins/notification/permissions/autogenerated/commands/remove_active.toml b/plugins/notification/permissions/autogenerated/commands/remove_active.toml new file mode 100644 index 0000000..9ad2add --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/remove_active.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-active" +description = "Enables the remove_active command without any pre-configured scope." +commands.allow = ["remove_active"] + +[[permission]] +identifier = "deny-remove-active" +description = "Denies the remove_active command without any pre-configured scope." +commands.deny = ["remove_active"] diff --git a/plugins/notification/permissions/autogenerated/commands/remove_listener.toml b/plugins/notification/permissions/autogenerated/commands/remove_listener.toml new file mode 100644 index 0000000..9f315e5 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/remove_listener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-listener" +description = "Enables the remove_listener command without any pre-configured scope." +commands.allow = ["remove_listener"] + +[[permission]] +identifier = "deny-remove-listener" +description = "Denies the remove_listener command without any pre-configured scope." +commands.deny = ["remove_listener"] diff --git a/plugins/notification/permissions/autogenerated/commands/request_permission.toml b/plugins/notification/permissions/autogenerated/commands/request_permission.toml new file mode 100644 index 0000000..0b410d6 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/request_permission.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-request-permission" +description = "Enables the request_permission command without any pre-configured scope." +commands.allow = ["request_permission"] + +[[permission]] +identifier = "deny-request-permission" +description = "Denies the request_permission command without any pre-configured scope." +commands.deny = ["request_permission"] diff --git a/plugins/notification/permissions/autogenerated/commands/show.toml b/plugins/notification/permissions/autogenerated/commands/show.toml new file mode 100644 index 0000000..3d4cbf3 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/show.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-show" +description = "Enables the show command without any pre-configured scope." +commands.allow = ["show"] + +[[permission]] +identifier = "deny-show" +description = "Denies the show command without any pre-configured scope." +commands.deny = ["show"] diff --git a/plugins/notification/permissions/autogenerated/reference.md b/plugins/notification/permissions/autogenerated/reference.md new file mode 100644 index 0000000..c79a441 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/reference.md @@ -0,0 +1,480 @@ +## Default Permission + +This permission set configures which +notification features are by default exposed. + +#### Granted Permissions + +It allows all notification related features. + +#### This default permission set includes the following: + +- `allow-is-permission-granted` +- `allow-request-permission` +- `allow-notify` +- `allow-register-action-types` +- `allow-register-listener` +- `allow-remove-listener` +- `allow-cancel` +- `allow-get-pending` +- `allow-remove-active` +- `allow-get-active` +- `allow-check-permissions` +- `allow-show` +- `allow-batch` +- `allow-list-channels` +- `allow-delete-channel` +- `allow-create-channel` +- `allow-permission-state` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`notification:allow-batch` + + + +Enables the batch command without any pre-configured scope. + +
+ +`notification:deny-batch` + + + +Denies the batch command without any pre-configured scope. + +
+ +`notification:allow-cancel` + + + +Enables the cancel command without any pre-configured scope. + +
+ +`notification:deny-cancel` + + + +Denies the cancel command without any pre-configured scope. + +
+ +`notification:allow-check-permissions` + + + +Enables the check_permissions command without any pre-configured scope. + +
+ +`notification:deny-check-permissions` + + + +Denies the check_permissions command without any pre-configured scope. + +
+ +`notification:allow-create-channel` + + + +Enables the create_channel command without any pre-configured scope. + +
+ +`notification:deny-create-channel` + + + +Denies the create_channel command without any pre-configured scope. + +
+ +`notification:allow-delete-channel` + + + +Enables the delete_channel command without any pre-configured scope. + +
+ +`notification:deny-delete-channel` + + + +Denies the delete_channel command without any pre-configured scope. + +
+ +`notification:allow-get-active` + + + +Enables the get_active command without any pre-configured scope. + +
+ +`notification:deny-get-active` + + + +Denies the get_active command without any pre-configured scope. + +
+ +`notification:allow-get-pending` + + + +Enables the get_pending command without any pre-configured scope. + +
+ +`notification:deny-get-pending` + + + +Denies the get_pending command without any pre-configured scope. + +
+ +`notification:allow-is-permission-granted` + + + +Enables the is_permission_granted command without any pre-configured scope. + +
+ +`notification:deny-is-permission-granted` + + + +Denies the is_permission_granted command without any pre-configured scope. + +
+ +`notification:allow-list-channels` + + + +Enables the list_channels command without any pre-configured scope. + +
+ +`notification:deny-list-channels` + + + +Denies the list_channels command without any pre-configured scope. + +
+ +`notification:allow-notify` + + + +Enables the notify command without any pre-configured scope. + +
+ +`notification:deny-notify` + + + +Denies the notify command without any pre-configured scope. + +
+ +`notification:allow-permission-state` + + + +Enables the permission_state command without any pre-configured scope. + +
+ +`notification:deny-permission-state` + + + +Denies the permission_state command without any pre-configured scope. + +
+ +`notification:allow-register-action-types` + + + +Enables the register_action_types command without any pre-configured scope. + +
+ +`notification:deny-register-action-types` + + + +Denies the register_action_types command without any pre-configured scope. + +
+ +`notification:allow-register-listener` + + + +Enables the register_listener command without any pre-configured scope. + +
+ +`notification:deny-register-listener` + + + +Denies the register_listener command without any pre-configured scope. + +
+ +`notification:allow-remove-active` + + + +Enables the remove_active command without any pre-configured scope. + +
+ +`notification:deny-remove-active` + + + +Denies the remove_active command without any pre-configured scope. + +
+ +`notification:allow-remove-listener` + + + +Enables the remove_listener command without any pre-configured scope. + +
+ +`notification:deny-remove-listener` + + + +Denies the remove_listener command without any pre-configured scope. + +
+ +`notification:allow-request-permission` + + + +Enables the request_permission command without any pre-configured scope. + +
+ +`notification:deny-request-permission` + + + +Denies the request_permission command without any pre-configured scope. + +
+ +`notification:allow-show` + + + +Enables the show command without any pre-configured scope. + +
+ +`notification:deny-show` + + + +Denies the show command without any pre-configured scope. + +
diff --git a/plugins/notification/permissions/default.toml b/plugins/notification/permissions/default.toml new file mode 100644 index 0000000..76d9e27 --- /dev/null +++ b/plugins/notification/permissions/default.toml @@ -0,0 +1,31 @@ +"$schema" = "schemas/schema.json" +[default] +description = """ +This permission set configures which +notification features are by default exposed. + +#### Granted Permissions + +It allows all notification related features. + +""" + +permissions = [ + "allow-is-permission-granted", + "allow-request-permission", + "allow-notify", + "allow-register-action-types", + "allow-register-listener", + "allow-remove-listener", + "allow-cancel", + "allow-get-pending", + "allow-remove-active", + "allow-get-active", + "allow-check-permissions", + "allow-show", + "allow-batch", + "allow-list-channels", + "allow-delete-channel", + "allow-create-channel", + "allow-permission-state", +] diff --git a/plugins/notification/permissions/schemas/schema.json b/plugins/notification/permissions/schemas/schema.json new file mode 100644 index 0000000..bea41b7 --- /dev/null +++ b/plugins/notification/permissions/schemas/schema.json @@ -0,0 +1,510 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the batch command without any pre-configured scope.", + "type": "string", + "const": "allow-batch", + "markdownDescription": "Enables the batch command without any pre-configured scope." + }, + { + "description": "Denies the batch command without any pre-configured scope.", + "type": "string", + "const": "deny-batch", + "markdownDescription": "Denies the batch command without any pre-configured scope." + }, + { + "description": "Enables the cancel command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel", + "markdownDescription": "Enables the cancel command without any pre-configured scope." + }, + { + "description": "Denies the cancel command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel", + "markdownDescription": "Denies the cancel command without any pre-configured scope." + }, + { + "description": "Enables the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "allow-check-permissions", + "markdownDescription": "Enables the check_permissions command without any pre-configured scope." + }, + { + "description": "Denies the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "deny-check-permissions", + "markdownDescription": "Denies the check_permissions command without any pre-configured scope." + }, + { + "description": "Enables the create_channel command without any pre-configured scope.", + "type": "string", + "const": "allow-create-channel", + "markdownDescription": "Enables the create_channel command without any pre-configured scope." + }, + { + "description": "Denies the create_channel command without any pre-configured scope.", + "type": "string", + "const": "deny-create-channel", + "markdownDescription": "Denies the create_channel command without any pre-configured scope." + }, + { + "description": "Enables the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "allow-delete-channel", + "markdownDescription": "Enables the delete_channel command without any pre-configured scope." + }, + { + "description": "Denies the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "deny-delete-channel", + "markdownDescription": "Denies the delete_channel command without any pre-configured scope." + }, + { + "description": "Enables the get_active command without any pre-configured scope.", + "type": "string", + "const": "allow-get-active", + "markdownDescription": "Enables the get_active command without any pre-configured scope." + }, + { + "description": "Denies the get_active command without any pre-configured scope.", + "type": "string", + "const": "deny-get-active", + "markdownDescription": "Denies the get_active command without any pre-configured scope." + }, + { + "description": "Enables the get_pending command without any pre-configured scope.", + "type": "string", + "const": "allow-get-pending", + "markdownDescription": "Enables the get_pending command without any pre-configured scope." + }, + { + "description": "Denies the get_pending command without any pre-configured scope.", + "type": "string", + "const": "deny-get-pending", + "markdownDescription": "Denies the get_pending command without any pre-configured scope." + }, + { + "description": "Enables the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "allow-is-permission-granted", + "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Denies the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "deny-is-permission-granted", + "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Enables the list_channels command without any pre-configured scope.", + "type": "string", + "const": "allow-list-channels", + "markdownDescription": "Enables the list_channels command without any pre-configured scope." + }, + { + "description": "Denies the list_channels command without any pre-configured scope.", + "type": "string", + "const": "deny-list-channels", + "markdownDescription": "Denies the list_channels command without any pre-configured scope." + }, + { + "description": "Enables the notify command without any pre-configured scope.", + "type": "string", + "const": "allow-notify", + "markdownDescription": "Enables the notify command without any pre-configured scope." + }, + { + "description": "Denies the notify command without any pre-configured scope.", + "type": "string", + "const": "deny-notify", + "markdownDescription": "Denies the notify command without any pre-configured scope." + }, + { + "description": "Enables the permission_state command without any pre-configured scope.", + "type": "string", + "const": "allow-permission-state", + "markdownDescription": "Enables the permission_state command without any pre-configured scope." + }, + { + "description": "Denies the permission_state command without any pre-configured scope.", + "type": "string", + "const": "deny-permission-state", + "markdownDescription": "Denies the permission_state command without any pre-configured scope." + }, + { + "description": "Enables the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "allow-register-action-types", + "markdownDescription": "Enables the register_action_types command without any pre-configured scope." + }, + { + "description": "Denies the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "deny-register-action-types", + "markdownDescription": "Denies the register_action_types command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_active command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-active", + "markdownDescription": "Enables the remove_active command without any pre-configured scope." + }, + { + "description": "Denies the remove_active command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-active", + "markdownDescription": "Denies the remove_active command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the request_permission command without any pre-configured scope.", + "type": "string", + "const": "allow-request-permission", + "markdownDescription": "Enables the request_permission command without any pre-configured scope." + }, + { + "description": "Denies the request_permission command without any pre-configured scope.", + "type": "string", + "const": "deny-request-permission", + "markdownDescription": "Denies the request_permission command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/notification/rollup.config.js b/plugins/notification/rollup.config.js new file mode 100644 index 0000000..a7dbd4f --- /dev/null +++ b/plugins/notification/rollup.config.js @@ -0,0 +1,22 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' +import terser from '@rollup/plugin-terser' + +export default createConfig({ + additionalConfigs: { + input: 'guest-js/init.ts', + output: { + file: 'src/init-iife.js', + format: 'iife' + }, + plugins: [typescript(), terser(), nodeResolve()], + onwarn: (warning) => { + throw Object.assign(new Error(), warning) + } + } +}) diff --git a/plugins/notification/src/commands.rs b/plugins/notification/src/commands.rs new file mode 100644 index 0000000..99b96c5 --- /dev/null +++ b/plugins/notification/src/commands.rs @@ -0,0 +1,39 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{command, plugin::PermissionState, AppHandle, Runtime, State}; + +use crate::{Notification, NotificationData, Result}; + +#[command] +pub(crate) async fn is_permission_granted( + _app: AppHandle, + notification: State<'_, Notification>, +) -> Result> { + let state = notification.permission_state()?; + match state { + PermissionState::Granted => Ok(Some(true)), + PermissionState::Denied => Ok(Some(false)), + PermissionState::Prompt | PermissionState::PromptWithRationale => Ok(None), + } +} + +#[command] +pub(crate) async fn request_permission( + _app: AppHandle, + notification: State<'_, Notification>, +) -> Result { + notification.request_permission() +} + +#[command] +pub(crate) async fn notify( + _app: AppHandle, + notification: State<'_, Notification>, + options: NotificationData, +) -> Result<()> { + let mut builder = notification.builder(); + builder.data = options; + builder.show() +} diff --git a/plugins/notification/src/desktop.rs b/plugins/notification/src/desktop.rs new file mode 100644 index 0000000..5b41a6c --- /dev/null +++ b/plugins/notification/src/desktop.rs @@ -0,0 +1,330 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PermissionState, PluginApi}, + AppHandle, Runtime, +}; + +use crate::NotificationBuilder; + +/// Initializes the desktop implementation of the notification APIs. +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Notification(app.clone())) +} + +/// Access to the notification APIs. +/// +/// You can get an instance of this type via [`NotificationExt`](crate::NotificationExt) +pub struct Notification(AppHandle); + +impl crate::NotificationBuilder { + /// Shows the notification. + /// + /// When no title was set with [`Self::title`], the `productName` from the Tauri configuration is used instead. + /// Only the title, body, icon and sound of the notification are used on desktop; + /// the scheduling, grouping and action related options are ignored. + /// + /// The notification is dispatched on a background task, so this returns as soon as the payload is prepared. + /// + /// # Errors + /// + /// Returns an error when the notification could not be prepared, + /// e.g. when the path of the running executable cannot be resolved on Windows. + /// + /// ## Platform-specific + /// + /// - **Windows**: Not supported on Windows 7 unless the `windows7-compat` Cargo feature is enabled. + pub fn show(self) -> crate::Result<()> { + let mut notification = imp::Notification::new(self.app.config().identifier.clone()); + + if let Some(title) = self + .data + .title + .or_else(|| self.app.config().product_name.clone()) + { + notification = notification.title(title); + } + if let Some(body) = self.data.body { + notification = notification.body(body); + } + if let Some(icon) = self.data.icon { + notification = notification.icon(icon); + } + if let Some(sound) = self.data.sound { + notification = notification.sound(sound); + } + #[cfg(feature = "windows7-compat")] + { + notification.notify(&self.app)?; + } + #[cfg(not(feature = "windows7-compat"))] + notification.show()?; + + Ok(()) + } +} + +impl Notification { + /// Creates a new builder for a notification. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` + pub fn builder(&self) -> NotificationBuilder { + NotificationBuilder::new(self.0.clone()) + } + + /// Requests the permission to send notifications. + /// + /// Desktop applications do not need to ask for this permission, + /// so this always resolves to [`PermissionState::Granted`] without prompting the user. + pub fn request_permission(&self) -> crate::Result { + Ok(PermissionState::Granted) + } + + /// Checks whether the permission to send notifications was granted. + /// + /// Desktop applications do not need to ask for this permission, + /// so this always resolves to [`PermissionState::Granted`]. + pub fn permission_state(&self) -> crate::Result { + Ok(PermissionState::Granted) + } +} + +mod imp { + //! Types and functions related to desktop notifications. + + #[cfg(windows)] + use std::path::MAIN_SEPARATOR as SEP; + + /// The desktop notification definition. + /// + /// Allows you to construct a Notification data and send it. + /// + /// # Examples + /// ```rust,no_run + /// use tauri_plugin_notification::NotificationExt; + /// // first we build the application to access the Tauri configuration + /// let app = tauri::Builder::default() + /// // on an actual app, remove the string argument + /// .build(tauri::generate_context!("test/tauri.conf.json")) + /// .expect("error while building tauri application"); + /// + /// // shows a notification with the given title and body + /// app.notification() + /// .builder() + /// .title("New message") + /// .body("You've got a new message.") + /// .show(); + /// + /// // run the app + /// app.run(|_app_handle, _event| {}); + /// ``` + #[allow(dead_code)] + #[derive(Debug, Default)] + pub struct Notification { + /// The notification body. + body: Option, + /// The notification title. + title: Option, + /// The notification icon. + icon: Option, + /// The notification sound. + sound: Option, + /// The notification identifier + identifier: String, + } + + impl Notification { + /// Initializes a instance of a Notification. + pub fn new(identifier: impl Into) -> Self { + Self { + identifier: identifier.into(), + ..Default::default() + } + } + + /// Sets the notification body. + #[must_use] + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + /// Sets the notification title. + #[must_use] + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Sets the notification icon. + #[must_use] + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + /// Sets the notification sound file. + #[must_use] + pub fn sound(mut self, sound: impl Into) -> Self { + self.sound = Some(sound.into()); + self + } + + /// Shows the notification. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// Ok(()) + /// }) + /// .run(tauri::generate_context!("test/tauri.conf.json")) + /// .expect("error while running tauri application"); + /// ``` + /// + /// ## Platform-specific + /// + /// - **Windows**: Not supported on Windows 7. If your app targets it, enable the `windows7-compat` feature and use [`Self::notify`]. + #[cfg_attr( + all(not(docsrs), feature = "windows7-compat"), + deprecated = "This function does not work on Windows 7. Use `Self::notify` instead." + )] + pub fn show(self) -> crate::Result<()> { + let mut notification = notify_rust::Notification::new(); + if let Some(body) = self.body { + notification.body(&body); + } + if let Some(title) = self.title { + notification.summary(&title); + } + if let Some(icon) = self.icon { + notification.icon(&icon); + } else { + notification.auto_icon(); + } + if let Some(sound) = self.sound { + notification.sound_name(&sound); + } + #[cfg(windows)] + { + let exe = tauri::utils::platform::current_exe()?; + let exe_dir = exe.parent().expect("failed to get exe directory"); + let curr_dir = exe_dir.display().to_string(); + // set the notification's System.AppUserModel.ID only when running the installed app + if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str()) + || curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str())) + { + notification.app_id(&self.identifier); + } + } + #[cfg(target_os = "macos")] + { + let _ = notify_rust::set_application(if tauri::is_dev() { + "com.apple.Terminal" + } else { + &self.identifier + }); + } + + tauri::async_runtime::spawn(async move { + let _ = notification.show(); + }); + + Ok(()) + } + + /// Shows the notification. This API is similar to [`Self::show`], but it also works on Windows 7. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// tauri::Builder::default() + /// .setup(move |app| { + /// app.notification().builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// Ok(()) + /// }) + /// .run(tauri::generate_context!("test/tauri.conf.json")) + /// .expect("error while running tauri application"); + /// ``` + #[cfg(feature = "windows7-compat")] + #[cfg_attr(docsrs, doc(cfg(feature = "windows7-compat")))] + #[allow(unused_variables)] + pub fn notify(self, app: &tauri::AppHandle) -> crate::Result<()> { + #[cfg(windows)] + { + fn is_windows_7() -> bool { + let v = windows_version::OsVersion::current(); + // windows 7 is 6.1 + v.major == 6 && v.minor == 1 + } + + if is_windows_7() { + self.notify_win7(app) + } else { + #[allow(deprecated)] + self.show() + } + } + #[cfg(not(windows))] + { + #[allow(deprecated)] + self.show() + } + } + + /// Shows the notification on Windows 7. + #[cfg(all(windows, feature = "windows7-compat"))] + fn notify_win7(self, app: &tauri::AppHandle) -> crate::Result<()> { + let app_ = app.clone(); + let _ = app.clone().run_on_main_thread(move || { + let mut notification = win7_notifications::Notification::new(); + if let Some(body) = self.body { + notification.body(&body); + } + if let Some(title) = self.title { + notification.summary(&title); + } + if let Some(icon) = app_.default_window_icon() { + notification.icon(icon.rgba().to_vec(), icon.width(), icon.height()); + } + let _ = notification.show(); + }); + + Ok(()) + } + } +} diff --git a/plugins/notification/src/error.rs b/plugins/notification/src/error.rs new file mode 100644 index 0000000..f43da58 --- /dev/null +++ b/plugins/notification/src/error.rs @@ -0,0 +1,33 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{ser::Serializer, Serialize}; + +/// Alias for a [`std::result::Result`] with the error type set to [`Error`]. +pub type Result = std::result::Result; + +/// Errors returned by the notification plugin. +/// +/// The error is serialized to its [`Display`](std::fmt::Display) string when it crosses the IPC boundary. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An I/O operation failed, e.g. resolving the path of the running executable on Windows. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Failed to run a command on the mobile plugin implementation (Kotlin on Android, Swift on iOS). + /// + /// Only available on mobile. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/notification/src/init-iife.js b/plugins/notification/src/init-iife.js new file mode 100644 index 0000000..141e4a4 --- /dev/null +++ b/plugins/notification/src/init-iife.js @@ -0,0 +1 @@ +!function(){"use strict";async function i(i,n={},t){return window.__TAURI_INTERNALS__.invoke(i,n,t)}"function"==typeof SuppressedError&&SuppressedError,function(){let n=!1,t="default";function o(i){n=!0,window.Notification.permission=i,n=!1}window.Notification=function(n,t){const o=t||{};!async function(n){"object"==typeof n&&Object.freeze(n),await i("plugin:notification|notify",{options:"string"==typeof n?{title:n}:n})}(Object.assign(o,{title:n}))},window.Notification.requestPermission=async function(){return await i("plugin:notification|request_permission").then(i=>(o("prompt"===i||"prompt-with-rationale"===i?"default":i),i))},Object.defineProperty(window.Notification,"permission",{enumerable:!0,get:()=>t,set:i=>{if(!n)throw new Error("Readonly property");t=i}}),async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):!!__TEMPLATE_windows__||await i("plugin:notification|is_permission_granted")}().then(function(i){o(null===i?"default":i?"granted":"denied")})}()}(); diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs new file mode 100644 index 0000000..32c2e61 --- /dev/null +++ b/plugins/notification/src/lib.rs @@ -0,0 +1,265 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Send message notifications (brief auto-expiring OS window element) to your user. Can also be used with the Notification Web API. +//! +//! ## Cargo features +//! +//! - **windows7-compat**: Adds support for the legacy Windows 7 notification implementation and Windows-version detection. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use serde::Serialize; +#[cfg(mobile)] +use tauri::plugin::PluginHandle; +#[cfg(desktop)] +use tauri::AppHandle; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; +pub use tauri::plugin::PermissionState; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +pub use desktop::Notification; +#[cfg(mobile)] +pub use mobile::Notification; + +/// The notification builder. +#[derive(Debug)] +pub struct NotificationBuilder { + #[cfg(desktop)] + app: AppHandle, + #[cfg(mobile)] + handle: PluginHandle, + pub(crate) data: NotificationData, +} + +impl NotificationBuilder { + #[cfg(desktop)] + fn new(app: AppHandle) -> Self { + Self { + app, + data: Default::default(), + } + } + + #[cfg(mobile)] + fn new(handle: PluginHandle) -> Self { + Self { + handle, + data: Default::default(), + } + } + + /// Sets the notification identifier. + pub fn id(mut self, id: i32) -> Self { + self.data.id = id; + self + } + + /// Sets the identifier of the notification channel that delivers this notification. + /// + /// If the channel does not exist, the notification won't fire. + /// Make sure the channel exists with `Notification::list_channels` and + /// `Notification::create_channel`. + /// + /// Only used on Android. + pub fn channel_id(mut self, id: impl Into) -> Self { + self.data.channel_id.replace(id.into()); + self + } + + /// Sets the notification title. + pub fn title(mut self, title: impl Into) -> Self { + self.data.title.replace(title.into()); + self + } + + /// Sets the notification body. + pub fn body(mut self, body: impl Into) -> Self { + self.data.body.replace(body.into()); + self + } + + /// Schedule this notification to fire on a later time or a fixed interval. + pub fn schedule(mut self, schedule: Schedule) -> Self { + self.data.schedule.replace(schedule); + self + } + + /// Multiline text. + /// Changes the notification style to big text. + /// Cannot be used with `inboxLines`. + pub fn large_body(mut self, large_body: impl Into) -> Self { + self.data.large_body.replace(large_body.into()); + self + } + + /// Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + pub fn summary(mut self, summary: impl Into) -> Self { + self.data.summary.replace(summary.into()); + self + } + + /// Defines an action type for this notification. + pub fn action_type_id(mut self, action_type_id: impl Into) -> Self { + self.data.action_type_id.replace(action_type_id.into()); + self + } + + /// Identifier used to group multiple notifications. + /// + /// + pub fn group(mut self, group: impl Into) -> Self { + self.data.group.replace(group.into()); + self + } + + /// Instructs the system that this notification is the summary of a group on Android. + pub fn group_summary(mut self) -> Self { + self.data.group_summary = true; + self + } + + /// The sound resource name for the notification. + pub fn sound(mut self, sound: impl Into) -> Self { + self.data.sound.replace(sound.into()); + self + } + + /// Append an inbox line to the notification. + /// Changes the notification style to inbox. + /// Cannot be used with `largeBody`. + /// + /// Only supports up to 5 lines. + pub fn inbox_line(mut self, line: impl Into) -> Self { + self.data.inbox_lines.push(line.into()); + self + } + + /// Notification icon. + /// + /// On Android the icon must be placed in the app's `res/drawable` folder. + pub fn icon(mut self, icon: impl Into) -> Self { + self.data.icon.replace(icon.into()); + self + } + + /// Notification large icon (Android). + /// + /// The icon must be placed in the app's `res/drawable` folder. + pub fn large_icon(mut self, large_icon: impl Into) -> Self { + self.data.large_icon.replace(large_icon.into()); + self + } + + /// Icon color on Android. + pub fn icon_color(mut self, icon_color: impl Into) -> Self { + self.data.icon_color.replace(icon_color.into()); + self + } + + /// Append an attachment to the notification. + pub fn attachment(mut self, attachment: Attachment) -> Self { + self.data.attachments.push(attachment); + self + } + + /// Adds an extra payload to store in the notification. + pub fn extra(mut self, key: impl Into, value: impl Serialize) -> Self { + self.data + .extra + .insert(key.into(), serde_json::to_value(value).unwrap()); + self + } + + /// If true, the notification cannot be dismissed by the user on Android. + /// + /// An application service must manage the dismissal of the notification. + /// It is typically used to indicate a background task that is pending (e.g. a file download) + /// or the user is engaged with (e.g. playing music). + pub fn ongoing(mut self) -> Self { + self.data.ongoing = true; + self + } + + /// Automatically cancel the notification when the user clicks on it. + pub fn auto_cancel(mut self) -> Self { + self.data.auto_cancel = true; + self + } + + /// Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + pub fn silent(mut self) -> Self { + self.data.silent = true; + self + } +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the notification APIs. +pub trait NotificationExt { + /// Returns the notification APIs managed by the plugin. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` + fn notification(&self) -> &Notification; +} + +impl> crate::NotificationExt for T { + fn notification(&self) -> &Notification { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("notification") + .invoke_handler(tauri::generate_handler![ + commands::notify, + commands::request_permission, + commands::is_permission_granted + ]) + .js_init_script(include_str!("init-iife.js").replace( + "__TEMPLATE_windows__", + if cfg!(windows) { "true" } else { "false" }, + )) + .setup(|app, api| { + #[cfg(mobile)] + let notification = mobile::init(app, api)?; + #[cfg(desktop)] + let notification = desktop::init(app, api)?; + app.manage(notification); + Ok(()) + }) + .build() +} diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs new file mode 100644 index 0000000..250532d --- /dev/null +++ b/plugins/notification/src/mobile.rs @@ -0,0 +1,202 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Deserialize}; +use tauri::{ + plugin::{PermissionState, PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +use std::collections::HashMap; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_notification); + +/// Initializes the mobile implementation of the notification APIs by registering +/// the Kotlin (Android) or Swift (iOS) plugin class. +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "NotificationPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_notification)?; + Ok(Notification(handle)) +} + +impl crate::NotificationBuilder { + /// Shows the notification, or schedules it when [`Self::schedule`] was called. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) when the mobile plugin + /// rejects the notification, e.g. when the scheduled date is in the past. + pub fn show(self) -> crate::Result<()> { + self.handle + .run_mobile_plugin::("show", self.data) + .map(|_| ()) + .map_err(Into::into) + } +} + +/// Access to the notification APIs. +/// +/// You can get an instance of this type via [`NotificationExt`](crate::NotificationExt) +pub struct Notification(PluginHandle); + +impl Notification { + /// Creates a new builder for a notification. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` + pub fn builder(&self) -> crate::NotificationBuilder { + crate::NotificationBuilder::new(self.0.clone()) + } + + /// Requests the permission to send notifications, prompting the user when it was not decided yet. + /// + /// On Android this requests the `POST_NOTIFICATIONS` runtime permission. + pub fn request_permission(&self) -> crate::Result { + self.0 + .run_mobile_plugin::("requestPermissions", ()) + .map(|r| r.permission_state) + .map_err(Into::into) + } + + /// Checks the current state of the permission to send notifications without prompting the user. + pub fn permission_state(&self) -> crate::Result { + self.0 + .run_mobile_plugin::("checkPermissions", ()) + .map(|r| r.permission_state) + .map_err(Into::into) + } + + /// Registers the action types a notification can reference + /// through [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id). + /// + /// ## Platform-specific + /// + /// - **Android**: only the identifier, title and input flag of each [`Action`] are used. + /// - **iOS**: each action type is registered as a `UNNotificationCategory`. + pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("types", types); + self.0 + .run_mobile_plugin("registerActionTypes", args) + .map_err(Into::into) + } + + /// Removes the delivered notifications with the given identifiers from the notification center. + /// + /// Use [`Self::remove_all_active`] to remove every delivered notification. + pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.0 + .run_mobile_plugin("removeActive", args) + .map_err(Into::into) + } + + /// Lists the notifications that were delivered and are still visible in the notification center. + pub fn active(&self) -> crate::Result> { + self.0 + .run_mobile_plugin("getActive", ()) + .map_err(Into::into) + } + + /// Removes all delivered notifications from the notification center. + pub fn remove_all_active(&self) -> crate::Result<()> { + self.0 + .run_mobile_plugin("removeActive", ()) + .map_err(Into::into) + } + + /// Lists the scheduled notifications that have not been delivered yet. + pub fn pending(&self) -> crate::Result> { + self.0 + .run_mobile_plugin("getPending", ()) + .map_err(Into::into) + } + + /// Cancel pending notifications. + pub fn cancel(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("notifications", notifications); + self.0.run_mobile_plugin("cancel", args).map_err(Into::into) + } + + /// Cancel all pending notifications. + pub fn cancel_all(&self) -> crate::Result<()> { + self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) + } + + /// Creates a notification channel, which notifications can target + /// through [`NotificationBuilder::channel_id`](crate::NotificationBuilder::channel_id). + /// + /// Notifications that reference a channel that does not exist are not delivered. + /// + /// Only available on Android. + #[cfg(target_os = "android")] + pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { + self.0 + .run_mobile_plugin("createChannel", channel) + .map_err(Into::into) + } + + /// Deletes the notification channel with the given identifier. + /// + /// Only available on Android. + #[cfg(target_os = "android")] + pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("id", id.into()); + self.0 + .run_mobile_plugin("deleteChannel", args) + .map_err(Into::into) + } + + /// Lists the notification channels that are currently registered for the app. + /// + /// Only available on Android. + #[cfg(target_os = "android")] + pub fn list_channels(&self) -> crate::Result> { + self.0 + .run_mobile_plugin("listChannels", ()) + .map_err(Into::into) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PermissionResponse { + permission_state: PermissionState, +} diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs new file mode 100644 index 0000000..ce5993a --- /dev/null +++ b/plugins/notification/src/models.rs @@ -0,0 +1,914 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{collections::HashMap, fmt::Display}; + +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; + +use url::Url; + +/// A media file attached to a notification. +/// +/// Attachments are only used on mobile; desktop notifications ignore them. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + id: String, + url: Url, +} + +impl Attachment { + /// Creates a new attachment with the given identifier and URL. + /// + /// The URL accepts the `asset` and `file` protocols. + pub fn new(id: impl Into, url: Url) -> Self { + Self { id: id.into(), url } + } +} + +/// The set of date fields a notification must match to be delivered. +/// +/// Fields left as [`None`] match any value, so the notification fires on every date +/// whose remaining components match. Used by [`Schedule::Interval`]. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleInterval { + /// The year the notification fires on. + pub year: Option, + /// The month of the year the notification fires on. + pub month: Option, + /// The day of the month the notification fires on. + pub day: Option, + /// The day of the week the notification fires on. + /// + /// 1 - Sunday, 2 - Monday, 3 - Tuesday, 4 - Wednesday, 5 - Thursday, 6 - Friday, 7 - Saturday. + pub weekday: Option, + /// The hour of the day the notification fires on, in the 24-hour clock. + pub hour: Option, + /// The minute of the hour the notification fires on. + pub minute: Option, + /// The second of the minute the notification fires on. + pub second: Option, +} + +/// The unit of the repeating interval used by [`Schedule::Every`]. +/// +/// It is serialized as its lowercase camelCase name, e.g. `twoWeeks`. +#[derive(Debug)] +pub enum ScheduleEvery { + /// Repeats every year. + /// + /// On Android a year is approximated as 52 weeks. + Year, + /// Repeats every month. + /// + /// On Android a month is approximated as 30 days. + Month, + /// Repeats every two weeks. + TwoWeeks, + /// Repeats every week. + Week, + /// Repeats every day. + Day, + /// Repeats every hour. + Hour, + /// Repeats every minute. + Minute, + /// Repeats every second. + /// + /// Not supported on iOS, where repeating triggers must be at least a minute apart. + Second, +} + +impl Display for ScheduleEvery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Year => "year", + Self::Month => "month", + Self::TwoWeeks => "twoWeeks", + Self::Week => "week", + Self::Day => "day", + Self::Hour => "hour", + Self::Minute => "minute", + Self::Second => "second", + } + ) + } +} + +impl Serialize for ScheduleEvery { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +impl<'de> Deserialize<'de> for ScheduleEvery { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "year" => Ok(Self::Year), + "month" => Ok(Self::Month), + "twoweeks" => Ok(Self::TwoWeeks), + "week" => Ok(Self::Week), + "day" => Ok(Self::Day), + "hour" => Ok(Self::Hour), + "minute" => Ok(Self::Minute), + "second" => Ok(Self::Second), + _ => Err(DeError::custom(format!("unknown every kind '{s}'"))), + } + } +} + +/// Defines when a notification is delivered. +/// +/// Scheduling is only implemented on mobile; the desktop implementation delivers the +/// notification immediately and ignores the schedule. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Schedule { + /// Fires at a specific date and time, which must be in the future. + #[serde(rename_all = "camelCase")] + At { + /// The date and time the notification fires at, serialized as an ISO-8601 string. + #[serde( + serialize_with = "iso8601::serialize", + deserialize_with = "time::serde::iso8601::deserialize" + )] + date: time::OffsetDateTime, + /// Whether the notification keeps repeating, using the duration between the moment it is + /// scheduled and `date` as the interval. Defaults to `false`. + /// + /// The interval must be at least one minute on iOS. + #[serde(default)] + repeating: bool, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. + #[serde(default)] + allow_while_idle: bool, + }, + /// Fires whenever the current date matches every field set on the given interval. + #[serde(rename_all = "camelCase")] + Interval { + /// The date fields the current date must match for the notification to fire. + interval: ScheduleInterval, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. + #[serde(default)] + allow_while_idle: bool, + }, + /// Fires repeatedly, once every `count` times the given interval unit. + #[serde(rename_all = "camelCase")] + Every { + /// The unit of the repeating interval. + interval: ScheduleEvery, + /// How many interval units elapse between each notification. + count: u8, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. + #[serde(default)] + allow_while_idle: bool, + }, +} + +// custom ISO-8601 serialization that does not use 6 digits for years. +mod iso8601 { + use serde::{ser::Error as _, Serialize, Serializer}; + use time::{ + format_description::well_known::iso8601::{Config, EncodedConfig}, + format_description::well_known::Iso8601, + OffsetDateTime, + }; + + const SERDE_CONFIG: EncodedConfig = Config::DEFAULT.encode(); + + pub fn serialize( + datetime: &OffsetDateTime, + serializer: S, + ) -> Result { + datetime + .format(&Iso8601::) + .map_err(S::Error::custom)? + .serialize(serializer) + } +} + +/// The payload of a notification, as sent to the platform implementation. +/// +/// Build it with [`NotificationBuilder`](crate::NotificationBuilder) rather than constructing it directly. +/// The identifier defaults to a random 32-bit integer when it is not provided. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationData { + #[serde(default = "default_id")] + pub(crate) id: i32, + pub(crate) channel_id: Option, + pub(crate) title: Option, + pub(crate) body: Option, + pub(crate) schedule: Option, + pub(crate) large_body: Option, + pub(crate) summary: Option, + pub(crate) action_type_id: Option, + pub(crate) group: Option, + #[serde(default)] + pub(crate) group_summary: bool, + pub(crate) sound: Option, + #[serde(default)] + pub(crate) inbox_lines: Vec, + pub(crate) icon: Option, + pub(crate) large_icon: Option, + pub(crate) icon_color: Option, + #[serde(default)] + pub(crate) attachments: Vec, + #[serde(default)] + pub(crate) extra: HashMap, + #[serde(default)] + pub(crate) ongoing: bool, + #[serde(default)] + pub(crate) auto_cancel: bool, + #[serde(default)] + pub(crate) silent: bool, +} + +fn default_id() -> i32 { + rand::random() +} + +impl Default for NotificationData { + fn default() -> Self { + Self { + id: default_id(), + channel_id: None, + title: None, + body: None, + schedule: None, + large_body: None, + summary: None, + action_type_id: None, + group: None, + group_summary: false, + sound: None, + inbox_lines: Vec::new(), + icon: None, + large_icon: None, + icon_color: None, + attachments: Vec::new(), + extra: Default::default(), + ongoing: false, + auto_cancel: false, + silent: false, + } + } +} + +/// A notification that was scheduled and has not been delivered yet. +/// +/// Returned by `Notification::pending`, which is only available on mobile. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingNotification { + id: i32, + title: Option, + body: Option, + schedule: Schedule, +} + +impl PendingNotification { + /// The notification identifier. + pub fn id(&self) -> i32 { + self.id + } + + /// The notification title, if it was set. + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + /// The notification body, if it was set. + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + /// The schedule that determines when the notification is delivered. + pub fn schedule(&self) -> &Schedule { + &self.schedule + } +} + +/// A notification that was delivered and is still visible in the notification center. +/// +/// Returned by `Notification::active`, which is only available on mobile. +/// Which fields are populated depends on the platform, since Android and iOS expose +/// different information about delivered notifications. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveNotification { + id: i32, + tag: Option, + title: Option, + body: Option, + group: Option, + #[serde(default)] + group_summary: bool, + #[serde(default)] + data: HashMap, + #[serde(default)] + extra: HashMap, + #[serde(default)] + attachments: Vec, + action_type_id: Option, + schedule: Option, + sound: Option, +} + +impl ActiveNotification { + /// The notification identifier. + pub fn id(&self) -> i32 { + self.id + } + + /// The tag the notification was posted with. + /// + /// Only set on Android. + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + /// The notification title, if it was set. + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + /// The notification body, if it was set. + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + /// The identifier of the group the notification belongs to. + /// + /// Only set on Android. + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + /// Whether the notification is the summary of its group. + /// + /// Only set on Android. Defaults to `false`. + pub fn group_summary(&self) -> bool { + self.group_summary + } + + /// The platform extras attached to the notification, as string values. + /// + /// Only set on Android, where it holds the `android.app.Notification` extras bundle. + pub fn data(&self) -> &HashMap { + &self.data + } + + /// The extra payload that was stored in the notification. + pub fn extra(&self) -> &HashMap { + &self.extra + } + + /// The attachments of the notification. + /// + /// Only set on iOS. + pub fn attachments(&self) -> &[Attachment] { + &self.attachments + } + + /// The identifier of the action type the notification was registered with. + /// + /// Only set on iOS. + pub fn action_type_id(&self) -> Option<&str> { + self.action_type_id.as_deref() + } + + /// The schedule the notification was delivered with, if it was scheduled. + pub fn schedule(&self) -> Option<&Schedule> { + self.schedule.as_ref() + } + + /// The sound resource name of the notification. + /// + /// Only set on iOS. + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } +} + +/// A group of [`Action`]s a notification can display, referenced by +/// [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id). +/// +/// Register it with `Notification::register_action_types` before sending a notification that uses it. +/// It maps to a `UNNotificationCategory` on iOS and to an action group on Android. +/// +/// Only available on mobile. Use [`ActionType::builder`] to construct one. +#[cfg(mobile)] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionType { + id: String, + actions: Vec, + hidden_previews_body_placeholder: Option, + custom_dismiss_action: bool, + allow_in_car_play: bool, + hidden_previews_show_title: bool, + hidden_previews_show_subtitle: bool, +} + +/// Builder for an [`ActionType`], created with [`ActionType::builder`]. +/// +/// Only available on mobile. +#[cfg(mobile)] +#[derive(Debug)] +pub struct ActionTypeBuilder(ActionType); + +#[cfg(mobile)] +impl ActionType { + /// Creates a builder for an action type with the given identifier. + /// + /// All the optional settings default to `false` or [`None`]; + /// call [`ActionTypeBuilder::build`] to get the [`ActionType`]. + pub fn builder(id: impl Into) -> ActionTypeBuilder { + ActionTypeBuilder(Self { + id: id.into(), + actions: Vec::new(), + hidden_previews_body_placeholder: None, + custom_dismiss_action: false, + allow_in_car_play: false, + hidden_previews_show_title: false, + hidden_previews_show_subtitle: false, + }) + } + + /// The identifier of this action type. + pub fn id(&self) -> &str { + &self.id + } + + /// The actions associated with this action type. + pub fn actions(&self) -> &[Action] { + &self.actions + } + + /// The placeholder shown instead of the notification body when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_body_placeholder(&self) -> Option<&str> { + self.hidden_previews_body_placeholder.as_deref() + } + + /// Whether the app is notified when the user dismisses the notification. + /// + /// Only used on iOS. + pub fn custom_dismiss_action(&self) -> bool { + self.custom_dismiss_action + } + + /// Whether the notification can be displayed in a CarPlay environment. + /// + /// Only used on iOS. + pub fn allow_in_car_play(&self) -> bool { + self.allow_in_car_play + } + + /// Whether the notification title is shown even when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_show_title(&self) -> bool { + self.hidden_previews_show_title + } + + /// Whether the notification subtitle is shown even when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_show_subtitle(&self) -> bool { + self.hidden_previews_show_subtitle + } +} + +#[cfg(mobile)] +impl ActionTypeBuilder { + /// Sets the actions associated with this action type. + pub fn actions(mut self, actions: Vec) -> Self { + self.0.actions = actions; + self + } + + /// Sets the placeholder shown instead of the notification body when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_body_placeholder( + mut self, + hidden_previews_body_placeholder: impl Into, + ) -> Self { + self.0 + .hidden_previews_body_placeholder + .replace(hidden_previews_body_placeholder.into()); + self + } + + /// Sets whether the app is notified when the user dismisses the notification. + /// + /// Only used on iOS. + pub fn custom_dismiss_action(mut self, custom_dismiss_action: bool) -> Self { + self.0.custom_dismiss_action = custom_dismiss_action; + self + } + + /// Sets whether the notification can be displayed in a CarPlay environment. + /// + /// Only used on iOS. + pub fn allow_in_car_play(mut self, allow_in_car_play: bool) -> Self { + self.0.allow_in_car_play = allow_in_car_play; + self + } + + /// Sets whether the notification title is shown even when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_show_title(mut self, hidden_previews_show_title: bool) -> Self { + self.0.hidden_previews_show_title = hidden_previews_show_title; + self + } + + /// Sets whether the notification subtitle is shown even when previews are hidden. + /// + /// Only used on iOS. + pub fn hidden_previews_show_subtitle(mut self, hidden_previews_show_subtitle: bool) -> Self { + self.0.hidden_previews_show_subtitle = hidden_previews_show_subtitle; + self + } + + /// Builds the [`ActionType`]. + pub fn build(self) -> ActionType { + self.0 + } +} + +/// A button the user can tap on a notification, belonging to an [`ActionType`]. +/// +/// It maps to a `UNNotificationAction` on iOS. On Android only the identifier, the title +/// and the input flag are used. +/// +/// Only available on mobile. Use [`Action::builder`] to construct one. +#[cfg(mobile)] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Action { + id: String, + title: String, + requires_authentication: bool, + foreground: bool, + destructive: bool, + input: bool, + input_button_title: Option, + input_placeholder: Option, +} + +/// Builder for an [`Action`], created with [`Action::builder`]. +/// +/// Only available on mobile. +#[cfg(mobile)] +#[derive(Debug)] +pub struct ActionBuilder(Action); + +#[cfg(mobile)] +impl Action { + /// Creates a builder for an action with the given identifier and button title. + /// + /// All the optional settings default to `false` or [`None`]; + /// call [`ActionBuilder::build`] to get the [`Action`]. + pub fn builder(id: impl Into, title: impl Into) -> ActionBuilder { + ActionBuilder(Self { + id: id.into(), + title: title.into(), + requires_authentication: false, + foreground: false, + destructive: false, + input: false, + input_button_title: None, + input_placeholder: None, + }) + } + + /// The identifier of this action, reported back when the user triggers it. + pub fn id(&self) -> &str { + &self.id + } + + /// The text displayed on the action button. + pub fn title(&self) -> &str { + &self.title + } + + /// Whether the device must be unlocked for the action to run. + /// + /// Only used on iOS. + pub fn requires_authentication(&self) -> bool { + self.requires_authentication + } + + /// Whether the app is brought to the foreground when the action is triggered. + /// + /// Only used on iOS. + pub fn foreground(&self) -> bool { + self.foreground + } + + /// Whether the action is displayed as destructive, usually in red. + /// + /// Only used on iOS. + pub fn destructive(&self) -> bool { + self.destructive + } + + /// Whether triggering the action lets the user type a text response. + pub fn input(&self) -> bool { + self.input + } + + /// The text displayed on the button that submits the text input. + /// + /// Only used on iOS. + pub fn input_button_title(&self) -> Option<&str> { + self.input_button_title.as_deref() + } + + /// The placeholder displayed on the empty text input field. + /// + /// Only used on iOS. + pub fn input_placeholder(&self) -> Option<&str> { + self.input_placeholder.as_deref() + } +} + +#[cfg(mobile)] +impl ActionBuilder { + /// Sets whether the device must be unlocked for the action to run. + /// + /// Only used on iOS. + pub fn requires_authentication(mut self, requires_authentication: bool) -> Self { + self.0.requires_authentication = requires_authentication; + self + } + + /// Sets whether the app is brought to the foreground when the action is triggered. + /// + /// Only used on iOS. + pub fn foreground(mut self, foreground: bool) -> Self { + self.0.foreground = foreground; + self + } + + /// Sets whether the action is displayed as destructive, usually in red. + /// + /// Only used on iOS. + pub fn destructive(mut self, destructive: bool) -> Self { + self.0.destructive = destructive; + self + } + + /// Sets whether triggering the action lets the user type a text response. + pub fn input(mut self, input: bool) -> Self { + self.0.input = input; + self + } + + /// Sets the text displayed on the button that submits the text input. + /// + /// Only used on iOS. + pub fn input_button_title(mut self, input_button_title: impl Into) -> Self { + self.0.input_button_title.replace(input_button_title.into()); + self + } + + /// Sets the placeholder displayed on the empty text input field. + /// + /// Only used on iOS. + pub fn input_placeholder(mut self, input_placeholder: impl Into) -> Self { + self.0.input_placeholder.replace(input_placeholder.into()); + self + } + + /// Builds the [`Action`]. + pub fn build(self) -> Action { + self.0 + } +} + +#[cfg(target_os = "android")] +pub use android::*; + +#[cfg(target_os = "android")] +mod android { + use serde::{Deserialize, Serialize}; + use serde_repr::{Deserialize_repr, Serialize_repr}; + + /// How much the notifications of a [`Channel`] interrupt the user. + /// + /// It maps to the `NotificationManager.IMPORTANCE_*` constants and is serialized as its + /// integer value. Only available on Android. + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(u8)] + pub enum Importance { + /// The notifications are not shown. + None = 0, + /// The notifications are only shown in the shade, below the fold, without a status bar icon. + Min = 1, + /// The notifications are shown without a sound. + Low = 2, + /// The notifications are shown and make a sound. + /// + /// This is the value used when the channel does not define an importance. + Default = 3, + /// The notifications are shown, make a sound and pop up as a heads-up notification. + High = 4, + } + + impl Default for Importance { + fn default() -> Self { + Self::Default + } + } + + /// How much of a notification is shown on the lock screen. + /// + /// It maps to the `Notification.VISIBILITY_*` constants and is serialized as its + /// integer value. Only available on Android. + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(i8)] + pub enum Visibility { + /// The notification is not shown on the lock screen at all. + Secret = -1, + /// The notification is shown on the lock screen with its sensitive content hidden. + /// + /// This is the value used when the channel does not define a visibility. + Private = 0, + /// The notification is shown in full on the lock screen. + Public = 1, + } + + /// A notification channel, the category users configure notification behavior on. + /// + /// Notifications reference a channel through + /// [`NotificationBuilder::channel_id`](crate::NotificationBuilder::channel_id) and are not + /// delivered when the channel does not exist. Only available on Android. + /// Use [`Channel::builder`] to construct one. + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Channel { + id: String, + name: String, + description: Option, + sound: Option, + lights: bool, + light_color: Option, + vibration: bool, + importance: Importance, + visibility: Option, + } + + /// Builder for a [`Channel`], created with [`Channel::builder`]. + /// + /// Only available on Android. + #[derive(Debug)] + pub struct ChannelBuilder(Channel); + + impl Channel { + /// Creates a builder for a channel with the given identifier and user visible name. + /// + /// Lights and vibration are disabled, the importance defaults to [`Importance::Default`] + /// and the remaining settings default to [`None`]; + /// call [`ChannelBuilder::build`] to get the [`Channel`]. + pub fn builder(id: impl Into, name: impl Into) -> ChannelBuilder { + ChannelBuilder(Self { + id: id.into(), + name: name.into(), + description: None, + sound: None, + lights: false, + light_color: None, + vibration: false, + importance: Default::default(), + visibility: None, + }) + } + + /// The identifier of this channel. + pub fn id(&self) -> &str { + &self.id + } + + /// The user visible name of this channel. + pub fn name(&self) -> &str { + &self.name + } + + /// The user visible description of this channel. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// The name of the sound resource played by the notifications of this channel. + /// + /// The resource must be placed in the app's `res/raw` folder. + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } + + /// Whether the notifications of this channel blink the device light. + pub fn lights(&self) -> bool { + self.lights + } + + /// The color of the device light, as a color string such as `#ff0000`. + pub fn light_color(&self) -> Option<&str> { + self.light_color.as_deref() + } + + /// Whether the notifications of this channel vibrate the device. + pub fn vibration(&self) -> bool { + self.vibration + } + + /// How much the notifications of this channel interrupt the user. + pub fn importance(&self) -> Importance { + self.importance + } + + /// How much of the notifications of this channel is shown on the lock screen. + /// + /// [`Visibility::Private`] is used when this is [`None`]. + pub fn visibility(&self) -> Option { + self.visibility + } + } + + impl ChannelBuilder { + /// Sets the user visible description of the channel. + pub fn description(mut self, description: impl Into) -> Self { + self.0.description.replace(description.into()); + self + } + + /// Sets the name of the sound resource played by the notifications of this channel. + /// + /// The resource must be placed in the app's `res/raw` folder. + pub fn sound(mut self, sound: impl Into) -> Self { + self.0.sound.replace(sound.into()); + self + } + + /// Sets whether the notifications of this channel blink the device light. + pub fn lights(mut self, lights: bool) -> Self { + self.0.lights = lights; + self + } + + /// Sets the color of the device light, as a color string such as `#ff0000`. + pub fn light_color(mut self, color: impl Into) -> Self { + self.0.light_color.replace(color.into()); + self + } + + /// Sets whether the notifications of this channel vibrate the device. + pub fn vibration(mut self, vibration: bool) -> Self { + self.0.vibration = vibration; + self + } + + /// Sets how much the notifications of this channel interrupt the user. + pub fn importance(mut self, importance: Importance) -> Self { + self.0.importance = importance; + self + } + + /// Sets how much of the notifications of this channel is shown on the lock screen. + pub fn visibility(mut self, visibility: Visibility) -> Self { + self.0.visibility.replace(visibility); + self + } + + /// Builds the [`Channel`]. + pub fn build(self) -> Channel { + self.0 + } + } +} diff --git a/plugins/notification/test/tauri.conf.json b/plugins/notification/test/tauri.conf.json new file mode 100644 index 0000000..4d0ae02 --- /dev/null +++ b/plugins/notification/test/tauri.conf.json @@ -0,0 +1,21 @@ +{ + "identifier": "app.tauri.example", + "build": { + "frontendDist": ".", + "devUrl": "http://localhost:4000" + }, + "app": { + "windows": [ + { + "title": "Tauri App" + } + ], + "security": { + "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: http://tauri.localhost 'unsafe-eval' 'unsafe-inline' 'self'" + } + }, + "bundle": { + "active": true, + "icon": ["../../../examples/api/src-tauri/icons/icon.png"] + } +} diff --git a/plugins/notification/tsconfig.json b/plugins/notification/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/notification/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/opener/CHANGELOG.md b/plugins/opener/CHANGELOG.md new file mode 100644 index 0000000..e11e047 --- /dev/null +++ b/plugins/opener/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +## [2.5.5] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. + +## \[2.5.4] + +- [`c1fd33b3`](https://github.com/tauri-apps/plugins-workspace/commit/c1fd33b3a2735f2e25c1d026dc524af932db3315) ([#3343](https://github.com/tauri-apps/plugins-workspace/pull/3343) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `revealItemInDir`/`reveal_items_in_dir` can't reveal network paths like `\\wsl.localhost\Ubuntu\etc` on Windows + +## \[2.5.3] + +- [`3d0d2e04`](https://github.com/tauri-apps/plugins-workspace/commit/3d0d2e041bbad9766aebecaeba291a28d8d7bf5c) ([#3163](https://github.com/tauri-apps/plugins-workspace/pull/3163) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Properly ignore `with: inAppBrowser` on desktop. This prevents an issue were `open_url` seamingly did nothing on desktop. + +## \[2.5.2] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.5.1] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. +- [`67a7bf80`](https://github.com/tauri-apps/plugins-workspace/commit/67a7bf80f8e6f3aec76ebaba0983f0cf0103d3ff) ([#3018](https://github.com/tauri-apps/plugins-workspace/pull/3018) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix opener doesn't open same origin links in the browser + +## \[2.5.0] + +### enhance + +- [`b8056f48`](https://github.com/tauri-apps/plugins-workspace/commit/b8056f484c7144af095d4d6ded1e8adbb9b8a865) ([#2897](https://github.com/tauri-apps/plugins-workspace/pull/2897) by [@petersamokhin](https://github.com/tauri-apps/plugins-workspace/../../petersamokhin)) Allow reveal multiple items in the file explorer. + +## \[2.4.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.3.1] + +### feat + +- [`2aec8ff4`](https://github.com/tauri-apps/plugins-workspace/commit/2aec8ff4c41d178ea9804f7b6eff343c726be015) ([#2803](https://github.com/tauri-apps/plugins-workspace/pull/2803) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Add `inAppBrowser` option to open URLs in an in-app browser on Android and iOS. + +## \[2.3.0] + +- [`ce9888a2`](https://github.com/tauri-apps/plugins-workspace/commit/ce9888a2d4c9b449bd2a306f0fa6c76507fd46d3) ([#2762](https://github.com/tauri-apps/plugins-workspace/pull/2762)) Similar to the `fs` plugin the `opener` plugin now supports a `requireLiteralLeadingDot` configuration in `tauri.conf.json`. + +## \[2.2.7] + +- [`6c9e08dc`](https://github.com/tauri-apps/plugins-workspace/commit/6c9e08dccb3ac99fccfce586fa2b69717ba81b52) ([#2695](https://github.com/tauri-apps/plugins-workspace/pull/2695) by [@ShaunSHamilton](https://github.com/tauri-apps/plugins-workspace/../../ShaunSHamilton)) Adjust `open_url` url type to allow `URL` +- [`dde6f3c3`](https://github.com/tauri-apps/plugins-workspace/commit/dde6f3c31c1b79942abb556be31757dc583f509e) ([#2549](https://github.com/tauri-apps/plugins-workspace/pull/2549) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update `windows` crate to 0.61 to align with Tauri 2.5 + +## \[2.2.6] + +- [`1a984659`](https://github.com/tauri-apps/plugins-workspace/commit/1a9846599b6a71faf330845847a30f6bf9735898) ([#2469](https://github.com/tauri-apps/plugins-workspace/pull/2469) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Update `objc2` crate to 0.6. No user facing changes. +- [`71f95c9f`](https://github.com/tauri-apps/plugins-workspace/commit/71f95c9f05b29cf1be586849614c0b007757c15d) ([#2445](https://github.com/tauri-apps/plugins-workspace/pull/2445) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated `windows` crate to 0.60 to match Tauri 2.3.0. No user facing changes. + +## \[2.2.5] + +- [`5b821181`](https://github.com/tauri-apps/plugins-workspace/commit/5b8211815825ddae2dcc0c00520e0cfdff002763) ([#2332](https://github.com/tauri-apps/plugins-workspace/pull/2332) by [@betamos](https://github.com/tauri-apps/plugins-workspace/../../betamos)) Fix broken JS commands `opener.openPath` and `opener.openUrl` on mobile. + +## \[2.2.4] + +- [`da5c59e2`](https://github.com/tauri-apps/plugins-workspace/commit/da5c59e2fe879d177e3cfd52fcacce85440423cb) ([#2271](https://github.com/tauri-apps/plugins-workspace/pull/2271) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated `zbus` dependency to version 5. No API changes. + +## \[2.2.3] + +- [`a9ac1e3c`](https://github.com/tauri-apps/plugins-workspace/commit/a9ac1e3c939cec4338a9422ef02323c1d4dde6cd) ([#2253](https://github.com/tauri-apps/plugins-workspace/pull/2253) by [@betamos](https://github.com/tauri-apps/plugins-workspace/../../betamos)) Return an error in `open_path` if the file does not exist when opening with default application. + +## \[2.2.2] + +- [`ee0f65de`](https://github.com/tauri-apps/plugins-workspace/commit/ee0f65de5c645c244c5f0b638e0e0aab687cb9bf) ([#2207](https://github.com/tauri-apps/plugins-workspace/pull/2207) by [@universalappfactory](https://github.com/tauri-apps/plugins-workspace/../../universalappfactory)) Fixed OpenerPlugin packagename for android + +## \[2.2.1] + +- [`18dffc9d`](https://github.com/tauri-apps/plugins-workspace/commit/18dffc9dfecaf0c900e233e041d9ca36c92834b5) ([#2189](https://github.com/tauri-apps/plugins-workspace/pull/2189) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fix usage on iOS. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.0] + +- [`383e636a`](https://github.com/tauri-apps/plugins-workspace/commit/383e636a8e595aec1300999a8aeb7d9bf8c14632) ([#2019](https://github.com/tauri-apps/plugins-workspace/pull/2019) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Initial Release diff --git a/plugins/opener/Cargo.toml b/plugins/opener/Cargo.toml new file mode 100644 index 0000000..e17b530 --- /dev/null +++ b/plugins/opener/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "tauri-plugin-opener" +version = "2.5.5" +description = "Open files and URLs using their default application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-opener" + +# Platforms supported by the plugin +# Support levels are "full", "partial", "none", "unknown" +# Details of the support level are left to plugin maintainer +exclude = ["/guest-js", "/package.json", "/rollup.config.js", "/tsconfig.json"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "partial", notes = "Only allows to open URLs via `open`" } +ios = { level = "partial", notes = "Only allows to open URLs via `open`" } + + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } +schemars = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +thiserror = { workspace = true } +open = { version = "5", features = ["shellexecute-on-windows"] } +glob = { workspace = true } +dunce = { workspace = true } + +[target."cfg(windows)".dependencies.windows] +version = "0.61" +features = [ + "Win32_Foundation", + "Win32_UI_Shell_Common", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Com", + "Win32_System_Registry", + "Win32_Storage_FileSystem", +] + +[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\", target_os = \"openbsd\"))".dependencies] +zbus = { workspace = true } +url = { workspace = true } + +[target."cfg(target_os = \"macos\")".dependencies.objc2-app-kit] +version = "0.3" +default-features = false +features = ["std", "NSWorkspace"] + +[target."cfg(target_os = \"macos\")".dependencies.objc2-foundation] +version = "0.3" +default-features = false +features = ["std", "NSURL", "NSArray", "NSString"] + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } diff --git a/plugins/opener/LICENSE.spdx b/plugins/opener/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/opener/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/opener/LICENSE_APACHE-2.0 b/plugins/opener/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/opener/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/opener/LICENSE_MIT b/plugins/opener/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/opener/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/opener/README.md b/plugins/opener/README.md new file mode 100644 index 0000000..bf25c5b --- /dev/null +++ b/plugins/opener/README.md @@ -0,0 +1,143 @@ +![opener](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/opener/banner.png) + + + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-opener = "2.0.0" +# alternatively with Git: +tauri-plugin-opener = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + + + +```sh +pnpm add @tauri-apps/plugin-opener +# or +npm add @tauri-apps/plugin-opener +# or +yarn add @tauri-apps/plugin-opener +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { openUrl, openPath, revealItemInDir } from '@tauri-apps/plugin-opener' + +// Opens the URL in the default browser +await openUrl('https://example.com') +// Or with a specific browser/app +await openUrl('https://example.com', 'firefox') + +// Opens the path with the system's default app +await openPath('/path/to/file') +// Or with a specific app +await openPath('/path/to/file', 'firefox') + +// Reveal a path with the system's default explorer +await revealItemInDir('/path/to/file') + +// Reveal multiple paths with the system's default explorer +// Note: will be renamed to `revealItemsInDir` in the next major version +await revealItemInDir(['/path/to/file', '/path/to/another/file']) +``` + +### Usage from Rust + +You can also use those APIs from Rust: + +```rust +use tauri_plugin_opener::OpenerExt; + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + let opener = app.opener(); + + // Opens the URL in the default browser + opener.open_url("https://example.com", None::<&str>)?; + // Or with a specific browser/app + opener.open_url("https://example.com", Some("firefox"))?; + + // Opens the path with the system's default app + opener.open_path("/path/to/file", None::<&str>)?; + // Or with a specific app + opener.open_path("/path/to/file", Some("firefox"))?; + + // Reveal a path with the system's default explorer + opener.reveal_item_in_dir("/path/to/file")?; + + // Reveal multiple paths with the system's default explorer + opener.reveal_items_in_dir(["/path/to/file"])?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/opener/SECURITY.md b/plugins/opener/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/opener/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/opener/android/.gitignore b/plugins/opener/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/opener/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/opener/android/build.gradle.kts b/plugins/opener/android/build.gradle.kts new file mode 100644 index 0000000..9ed0957 --- /dev/null +++ b/plugins/opener/android/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.opener" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.9.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + implementation("androidx.browser:browser:1.8.0") + implementation(project(":tauri-android")) +} diff --git a/plugins/opener/android/consumer-rules.pro b/plugins/opener/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/opener/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/opener/android/settings.gradle b/plugins/opener/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/opener/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/opener/android/src/main/AndroidManifest.xml b/plugins/opener/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/plugins/opener/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/plugins/opener/android/src/main/java/OpenerPlugin.kt b/plugins/opener/android/src/main/java/OpenerPlugin.kt new file mode 100644 index 0000000..a65a2f5 --- /dev/null +++ b/plugins/opener/android/src/main/java/OpenerPlugin.kt @@ -0,0 +1,44 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.opener + +import android.app.Activity +import android.content.Intent +import androidx.browser.customtabs.CustomTabsIntent +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin +import androidx.core.net.toUri +import app.tauri.annotation.InvokeArg + +@InvokeArg +class OpenArgs { + lateinit var url: String + var with: String? = null +} + +@TauriPlugin +class OpenerPlugin(private val activity: Activity) : Plugin(activity) { + @Command + fun open(invoke: Invoke) { + try { + val args = invoke.parseArgs(OpenArgs::class.java) + + if (args.with == "inAppBrowser") { + val builder = CustomTabsIntent.Builder() + val intent = builder.build() + intent.launchUrl(activity, args.url.toUri()) + } else { + val intent = Intent(Intent.ACTION_VIEW, args.url.toUri()) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.applicationContext?.startActivity(intent) + } + invoke.resolve() + } catch (ex: Exception) { + invoke.reject(ex.message) + } + } +} diff --git a/plugins/opener/api-iife.js b/plugins/opener/api-iife.js new file mode 100644 index 0000000..57393c5 --- /dev/null +++ b/plugins/opener/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_OPENER__=function(n,e){"use strict";return n.openPath=async function(n,_){await e.invoke("plugin:opener|open_path",{path:n,with:_})},n.openUrl=async function(n,_){await e.invoke("plugin:opener|open_url",{url:n,with:_})},n.revealItemInDir=async function(n){const _="string"==typeof n?[n]:n;return e.invoke("plugin:opener|reveal_item_in_dir",{paths:_})},n}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"opener",{value:__TAURI_PLUGIN_OPENER__})} diff --git a/plugins/opener/build.rs b/plugins/opener/build.rs new file mode 100644 index 0000000..6769035 --- /dev/null +++ b/plugins/opener/build.rs @@ -0,0 +1,132 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +#[path = "src/scope_entry.rs"] +#[allow(dead_code)] +mod scope; + +/// Opener scope application. +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +#[allow(unused)] +#[derive(Default)] +enum Application { + /// Open in default application. + #[default] + Default, + /// If true, allow open with any application. + Enable(bool), + /// Allow specific application to open with. + App(String), +} + +/// Opener scope entry. +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +#[allow(unused)] +enum OpenerScopeEntry { + Url { + /// A URL that can be opened by the webview when using the Opener APIs. + /// + /// Wildcards can be used following the UNIX glob pattern. + /// + /// Examples: + /// + /// - "https://*" : allows all HTTPS origin + /// + /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path + /// + /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" + url: String, + /// An application to open this url with, for example: firefox. + #[serde(default)] + app: Application, + }, + Path { + /// A path that can be opened by the webview when using the Opener APIs. + /// + /// The pattern can start with a variable that resolves to a system base directory. + /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, + /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, + /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, + /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`. + path: PathBuf, + /// An application to open this path with, for example: xdg-open. + #[serde(default)] + app: Application, + }, +} + +// Ensure `OpenerScopeEntry` and `scope::EntryRaw` is kept in sync +fn _f() { + match (scope::EntryRaw::Url { + url: String::new(), + app: scope::Application::Enable(true), + }) { + scope::EntryRaw::Url { url, app } => OpenerScopeEntry::Url { + url, + app: match app { + scope::Application::Enable(p) => Application::Enable(p), + scope::Application::App(p) => Application::App(p), + scope::Application::Default => Application::Default, + }, + }, + scope::EntryRaw::Path { path, app } => OpenerScopeEntry::Path { + path, + app: match app { + scope::Application::Enable(p) => Application::Enable(p), + scope::Application::App(p) => Application::App(p), + scope::Application::Default => Application::Default, + }, + }, + }; + match (OpenerScopeEntry::Url { + url: String::new(), + app: Application::Enable(true), + }) { + OpenerScopeEntry::Url { url, app } => scope::EntryRaw::Url { + url, + app: match app { + Application::Enable(p) => scope::Application::Enable(p), + Application::App(p) => scope::Application::App(p), + Application::Default => scope::Application::Default, + }, + }, + OpenerScopeEntry::Path { path, app } => scope::EntryRaw::Path { + path, + app: match app { + Application::Enable(p) => scope::Application::Enable(p), + Application::App(p) => scope::Application::App(p), + Application::Default => scope::Application::Default, + }, + }, + }; +} + +const COMMANDS: &[&str] = &["open_url", "open_path", "reveal_item_in_dir"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .android_path("android") + .ios_path("ios") + .global_scope_schema(schemars::schema_for!(OpenerScopeEntry)) + .build(); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let mobile = target_os == "ios" || target_os == "android"; + alias("desktop", !mobile); + alias("mobile", mobile); +} + +// creates a cfg alias if `has_feature` is true. +// `alias` must be a snake case string. +fn alias(alias: &str, has_feature: bool) { + println!("cargo:rustc-check-cfg=cfg({alias})"); + if has_feature { + println!("cargo:rustc-cfg={alias}"); + } +} diff --git a/plugins/opener/guest-js/index.ts b/plugins/opener/guest-js/index.ts new file mode 100644 index 0000000..6b40da1 --- /dev/null +++ b/plugins/opener/guest-js/index.ts @@ -0,0 +1,99 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Open files and URLs using their default application. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the files and urls to be opened. + * + * ### Restricting access to the {@link open | `open`} API + * + * On the configuration object, `open: true` means that the {@link open} API can be used with any URL, + * as the argument is validated with the `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+` regex. + * You can change that regex by changing the boolean value to a string, e.g. `open: ^https://github.com/`. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * Opens a url with the system's default app, or the one specified with {@linkcode openWith}. + * + * @example + * ```typescript + * import { openUrl } from '@tauri-apps/plugin-opener'; + * + * // opens the given URL on the default browser: + * await openUrl('https://github.com/tauri-apps/tauri'); + * // opens the given URL using `firefox`: + * await openUrl('https://github.com/tauri-apps/tauri', 'firefox'); + * ``` + * + * @param url The URL to open. + * @param openWith The app to open the URL with. If not specified, defaults to the system default application for the specified url type. + * On mobile, `openWith` can be provided as `inAppBrowser` to open the URL in an in-app browser. Otherwise, it will open the URL in the system default browser. + * + * @since 2.0.0 + */ +export async function openUrl( + url: string | URL, + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + openWith?: 'inAppBrowser' | string +): Promise { + await invoke('plugin:opener|open_url', { + url, + with: openWith + }) +} + +/** + * Opens a path with the system's default app, or the one specified with {@linkcode openWith}. + * + * @example + * ```typescript + * import { openPath } from '@tauri-apps/plugin-opener'; + * + * // opens a file using the default program: + * await openPath('/path/to/file'); + * // opens a file using `vlc` command on Windows. + * await openPath('C:/path/to/file', 'vlc'); + * ``` + * + * @param path The path to open. + * @param openWith The app to open the path with. If not specified, defaults to the system default application for the specified path type. + * + * @since 2.0.0 + */ +export async function openPath(path: string, openWith?: string): Promise { + await invoke('plugin:opener|open_path', { + path, + with: openWith + }) +} + +/** + * Reveal a path with the system's default explorer. + * + * #### Platform-specific: + * + * - **Android / iOS:** Unsupported. + * + * @example + * ```typescript + * import { revealItemInDir } from '@tauri-apps/plugin-opener'; + * await revealItemInDir('/path/to/file'); + * await revealItemInDir([ '/path/to/file', '/path/to/another/file' ]); + * ``` + * + * @param path The path to reveal. + * + * @since 2.0.0 + */ +export async function revealItemInDir(path: string | string[]): Promise { + const paths = typeof path === 'string' ? [path] : path + return invoke('plugin:opener|reveal_item_in_dir', { paths }) +} diff --git a/plugins/opener/guest-js/init.ts b/plugins/opener/guest-js/init.ts new file mode 100644 index 0000000..d012386 --- /dev/null +++ b/plugins/opener/guest-js/init.ts @@ -0,0 +1,70 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Webview script injected by the opener plugin on every page load. + * + * It installs a `click` listener on `window` that intercepts clicks on `` elements whose + * `target` is `_blank` (or that are clicked while holding `Ctrl` or `Shift`) and whose `href` + * uses the `http:`, `https:`, `mailto:` or `tel:` protocol, cancels the navigation and opens the + * link with the system's default browser through the `plugin:opener|open_url` command instead. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +// open links with the API +window.addEventListener('click', function (evt) { + // return early if + if ( + // event was prevented + evt.defaultPrevented + // or not a left click + || evt.button !== 0 + // or meta key pressed + || evt.metaKey + // or al key pressed + || evt.altKey + ) + return + + const a = evt + .composedPath() + .find((el) => el instanceof Node && el.nodeName.toUpperCase() === 'A') as + | HTMLAnchorElement + | undefined + + // return early if + if ( + // not tirggered from element + !a + // or doesn't have a href + || !a.href + // or not supposed to be open in a new tab + || !( + a.target === '_blank' + // or ctrl key pressed + || evt.ctrlKey + // or shift key pressed + || evt.shiftKey + ) + ) + return + + const url = new URL(a.href) + + // return early if + if ( + // not default protocols + ['http:', 'https:', 'mailto:', 'tel:'].every((p) => url.protocol !== p) + ) + return + + evt.preventDefault() + + void invoke('plugin:opener|open_url', { + url + }) +}) diff --git a/plugins/opener/ios/Package.resolved b/plugins/opener/ios/Package.resolved new file mode 100644 index 0000000..5f998e0 --- /dev/null +++ b/plugins/opener/ios/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "SwiftRs", + "repositoryURL": "https://github.com/Brendonovich/swift-rs", + "state": { + "branch": null, + "revision": "b5ed223fcdab165bc21219c1925dc1e77e2bef5e", + "version": "1.0.6" + } + } + ] + }, + "version": 1 +} diff --git a/plugins/opener/ios/Package.swift b/plugins/opener/ios/Package.swift new file mode 100644 index 0000000..8d06936 --- /dev/null +++ b/plugins/opener/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-opener", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-opener", + type: .static, + targets: ["tauri-plugin-opener"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-opener", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/opener/ios/Sources/OpenerPlugin.swift b/plugins/opener/ios/Sources/OpenerPlugin.swift new file mode 100644 index 0000000..2311a3b --- /dev/null +++ b/plugins/opener/ios/Sources/OpenerPlugin.swift @@ -0,0 +1,46 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation +import SafariServices +import SwiftRs +import Tauri +import UIKit +import WebKit + +struct OpenArgs: Decodable { + let url: String + let with: String? +} + +class OpenerPlugin: Plugin { + @objc public func open(_ invoke: Invoke) throws { + do { + let args = try invoke.parseArgs(OpenArgs.self) + if let url = URL(string: args.url) { + if args.with == "inAppBrowser" { + DispatchQueue.main.async { + let safariVC = SFSafariViewController(url: url) + self.manager.viewController?.present(safariVC, animated: true) + } + } else { + if #available(iOS 10, *) { + UIApplication.shared.open(url, options: [:]) + } else { + UIApplication.shared.openURL(url) + } + } + + } + invoke.resolve() + } catch { + invoke.reject(error.localizedDescription) + } + } +} + +@_cdecl("init_plugin_opener") +func initPlugin() -> Plugin { + return OpenerPlugin() +} diff --git a/plugins/opener/package.json b/plugins/opener/package.json new file mode 100644 index 0000000..f51f27a --- /dev/null +++ b/plugins/opener/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-opener", + "version": "2.5.5", + "description": "Open files and URLs using their default application.", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/opener/permissions/allow-default-urls.toml b/plugins/opener/permissions/allow-default-urls.toml new file mode 100644 index 0000000..93495b8 --- /dev/null +++ b/plugins/opener/permissions/allow-default-urls.toml @@ -0,0 +1,17 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "allow-default-urls" +description = "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + +[[permission.scope.allow]] +url = "mailto:*" + +[[permission.scope.allow]] +url = "tel:*" + +[[permission.scope.allow]] +url = "http://*" + +[[permission.scope.allow]] +url = "https://*" diff --git a/plugins/opener/permissions/autogenerated/commands/open_path.toml b/plugins/opener/permissions/autogenerated/commands/open_path.toml new file mode 100644 index 0000000..ae67b93 --- /dev/null +++ b/plugins/opener/permissions/autogenerated/commands/open_path.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-open-path" +description = "Enables the open_path command without any pre-configured scope." +commands.allow = ["open_path"] + +[[permission]] +identifier = "deny-open-path" +description = "Denies the open_path command without any pre-configured scope." +commands.deny = ["open_path"] diff --git a/plugins/opener/permissions/autogenerated/commands/open_url.toml b/plugins/opener/permissions/autogenerated/commands/open_url.toml new file mode 100644 index 0000000..f1e694b --- /dev/null +++ b/plugins/opener/permissions/autogenerated/commands/open_url.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-open-url" +description = "Enables the open_url command without any pre-configured scope." +commands.allow = ["open_url"] + +[[permission]] +identifier = "deny-open-url" +description = "Denies the open_url command without any pre-configured scope." +commands.deny = ["open_url"] diff --git a/plugins/opener/permissions/autogenerated/commands/reveal_item_in_dir.toml b/plugins/opener/permissions/autogenerated/commands/reveal_item_in_dir.toml new file mode 100644 index 0000000..e669620 --- /dev/null +++ b/plugins/opener/permissions/autogenerated/commands/reveal_item_in_dir.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-reveal-item-in-dir" +description = "Enables the reveal_item_in_dir command without any pre-configured scope." +commands.allow = ["reveal_item_in_dir"] + +[[permission]] +identifier = "deny-reveal-item-in-dir" +description = "Denies the reveal_item_in_dir command without any pre-configured scope." +commands.deny = ["reveal_item_in_dir"] diff --git a/plugins/opener/permissions/autogenerated/reference.md b/plugins/opener/permissions/autogenerated/reference.md new file mode 100644 index 0000000..6ad6ba1 --- /dev/null +++ b/plugins/opener/permissions/autogenerated/reference.md @@ -0,0 +1,111 @@ +## Default Permission + +This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application +as well as reveal file in directories using default file explorer + +#### This default permission set includes the following: + +- `allow-open-url` +- `allow-reveal-item-in-dir` +- `allow-default-urls` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`opener:allow-default-urls` + + + +This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application. + +
+ +`opener:allow-open-path` + + + +Enables the open_path command without any pre-configured scope. + +
+ +`opener:deny-open-path` + + + +Denies the open_path command without any pre-configured scope. + +
+ +`opener:allow-open-url` + + + +Enables the open_url command without any pre-configured scope. + +
+ +`opener:deny-open-url` + + + +Denies the open_url command without any pre-configured scope. + +
+ +`opener:allow-reveal-item-in-dir` + + + +Enables the reveal_item_in_dir command without any pre-configured scope. + +
+ +`opener:deny-reveal-item-in-dir` + + + +Denies the reveal_item_in_dir command without any pre-configured scope. + +
diff --git a/plugins/opener/permissions/default.toml b/plugins/opener/permissions/default.toml new file mode 100644 index 0000000..846d6e5 --- /dev/null +++ b/plugins/opener/permissions/default.toml @@ -0,0 +1,10 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application +as well as reveal file in directories using default file explorer""" +permissions = [ + "allow-open-url", + "allow-reveal-item-in-dir", + "allow-default-urls", +] diff --git a/plugins/opener/permissions/schemas/schema.json b/plugins/opener/permissions/schemas/schema.json new file mode 100644 index 0000000..78c80ba --- /dev/null +++ b/plugins/opener/permissions/schemas/schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/opener/rollup.config.js b/plugins/opener/rollup.config.js new file mode 100644 index 0000000..a7dbd4f --- /dev/null +++ b/plugins/opener/rollup.config.js @@ -0,0 +1,22 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' +import terser from '@rollup/plugin-terser' + +export default createConfig({ + additionalConfigs: { + input: 'guest-js/init.ts', + output: { + file: 'src/init-iife.js', + format: 'iife' + }, + plugins: [typescript(), terser(), nodeResolve()], + onwarn: (warning) => { + throw Object.assign(new Error(), warning) + } + } +}) diff --git a/plugins/opener/src/commands.rs b/plugins/opener/src/commands.rs new file mode 100644 index 0000000..e22b41d --- /dev/null +++ b/plugins/opener/src/commands.rs @@ -0,0 +1,76 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::{Path, PathBuf}; + +use tauri::{ + ipc::{CommandScope, GlobalScope}, + AppHandle, Runtime, +}; + +use crate::{scope::Scope, Error, OpenerExt}; + +#[tauri::command] +pub async fn open_url( + app: AppHandle, + command_scope: CommandScope, + global_scope: GlobalScope, + url: String, + with: Option, +) -> crate::Result<()> { + let scope = Scope::new( + &app, + command_scope + .allows() + .iter() + .chain(global_scope.allows()) + .collect(), + command_scope + .denies() + .iter() + .chain(global_scope.denies()) + .collect(), + ); + + if scope.is_url_allowed(&url, with.as_deref()) { + app.opener().open_url(url, with) + } else { + Err(Error::ForbiddenUrl { url, with }) + } +} + +#[tauri::command] +pub async fn open_path( + app: AppHandle, + command_scope: CommandScope, + global_scope: GlobalScope, + path: String, + with: Option, +) -> crate::Result<()> { + let scope = Scope::new( + &app, + command_scope + .allows() + .iter() + .chain(global_scope.allows()) + .collect(), + command_scope + .denies() + .iter() + .chain(global_scope.denies()) + .collect(), + ); + + if scope.is_path_allowed(Path::new(&path), with.as_deref())? { + app.opener().open_path(path, with) + } else { + Err(Error::ForbiddenPath { path, with }) + } +} + +/// TODO: in the next major version, rename to `reveal_items_in_dir` +#[tauri::command] +pub async fn reveal_item_in_dir(paths: Vec) -> crate::Result<()> { + crate::reveal_items_in_dir(&paths) +} diff --git a/plugins/opener/src/config.rs b/plugins/opener/src/config.rs new file mode 100644 index 0000000..db3bae4 --- /dev/null +++ b/plugins/opener/src/config.rs @@ -0,0 +1,19 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + /// Whether or not paths that contain components that start with a `.` + /// will require that `.` appears literally in the pattern; `*`, `?`, `**`, + /// or `[...]` will not match. This is useful because such files are + /// conventionally considered hidden on Unix systems and it might be + /// desirable to skip them when listing files. + /// + /// Defaults to `true` on Unix systems and `false` on Windows + // dotfiles are not supposed to be exposed by default on unix + pub require_literal_leading_dot: Option, +} diff --git a/plugins/opener/src/error.rs b/plugins/opener/src/error.rs new file mode 100644 index 0000000..05642fb --- /dev/null +++ b/plugins/opener/src/error.rs @@ -0,0 +1,82 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +/// The error type returned by the opener plugin's commands and APIs. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Forwarded from a failed call into the mobile plugin runtime. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// Forwarded from a [`tauri::Error`], e.g. when resolving a scoped path fails. + #[error(transparent)] + Tauri(#[from] tauri::Error), + /// Forwarded from an [`std::io::Error`] raised while opening or revealing a path. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Forwarded from a [`serde_json::Error`] raised while (de)serializing a mobile plugin payload. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// The program passed as `with` is not a program name known by the underlying opener. + #[error("unknown program {0}")] + UnknownProgramName(String), + /// The path is not allowed by the opener scope, optionally together with the program it was requested to be opened with. + #[error("Not allowed to open path {}{}", .path, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())] + ForbiddenPath { + /// The path that was rejected by the scope. + path: String, + /// The program the path was requested to be opened with, if any. + with: Option, + }, + /// The URL is not allowed by the opener scope, optionally together with the program it was requested to be opened with. + #[error("Not allowed to open url {}{}", .url, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())] + ForbiddenUrl { + /// The URL that was rejected by the scope. + url: String, + /// The program the URL was requested to be opened with, if any. + with: Option, + }, + /// The requested API is not supported on the current platform, e.g. [`crate::reveal_item_in_dir`] on Android and iOS. + #[error("API not supported on the current platform")] + UnsupportedPlatform, + /// Forwarded from a Win32 API call, see [`windows::core::Error`]. + #[error(transparent)] + #[cfg(windows)] + Win32Error(#[from] windows::core::Error), + /// The given path has no parent directory, so it cannot be revealed in its containing folder. + #[error("Path doesn't have a parent: {0}")] + NoParent(PathBuf), + // TODO: Add the underlying io::Error to this variant + /// Failed to convert the path to a Windows `ITEMIDLIST` while preparing it to be revealed in the file explorer. + #[cfg(windows)] + #[error("Failed to convert path '{0}' to ITEMIDLIST")] + FailedToConvertPathToItemIdList(PathBuf), + /// Failed to convert the path to a `file://` URL, which is required to reveal it via D-Bus on Linux and BSD. + #[error("Failed to convert path to file:// url")] + FailedToConvertPathToFileUrl, + /// Forwarded from a [`zbus::Error`] raised while talking to the file manager or the desktop portal over D-Bus. + #[error(transparent)] + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + Zbus(#[from] zbus::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/opener/src/init-iife.js b/plugins/opener/src/init-iife.js new file mode 100644 index 0000000..02c66a6 --- /dev/null +++ b/plugins/opener/src/init-iife.js @@ -0,0 +1 @@ +!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError,window.addEventListener("click",function(e){if(e.defaultPrevented||0!==e.button||e.metaKey||e.altKey)return;const t=e.composedPath().find(e=>e instanceof Node&&"A"===e.nodeName.toUpperCase());if(!t||!t.href||"_blank"!==t.target&&!e.ctrlKey&&!e.shiftKey)return;const n=new URL(t.href);["http:","https:","mailto:","tel:"].every(e=>n.protocol!==e)||(e.preventDefault(),async function(e,t={},n){window.__TAURI_INTERNALS__.invoke(e,t,n)}("plugin:opener|open_url",{url:n}))})}(); diff --git a/plugins/opener/src/lib.rs b/plugins/opener/src/lib.rs new file mode 100644 index 0000000..13f6efb --- /dev/null +++ b/plugins/opener/src/lib.rs @@ -0,0 +1,269 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Open files and URLs using their default application, and reveal files in the system's file explorer. +//! +//! Use the [`OpenerExt`] trait to access [`Opener::open_url`] and [`Opener::open_path`] from a +//! running Tauri app; the plugin's `open_url` and `open_path` commands enforce the scope +//! configured for the plugin before delegating to them. The [`open_url`] and [`open_path`] free +//! functions and the [`Opener`] methods themselves do not perform any scope check. +//! [`reveal_item_in_dir`] and [`reveal_items_in_dir`] return [`Error::UnsupportedPlatform`] on +//! Android and iOS. + +use std::path::Path; + +use tauri::{plugin::TauriPlugin, Manager, Runtime}; + +#[cfg(mobile)] +use tauri::plugin::PluginHandle; +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.opener"; +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_opener); + +mod commands; +mod config; +mod error; +mod open; +mod reveal_item_in_dir; +mod scope; +mod scope_entry; +#[cfg(windows)] +mod windows_shell_path; + +pub use error::Error; +type Result = std::result::Result; + +pub use open::{open_path, open_url}; +pub use reveal_item_in_dir::{reveal_item_in_dir, reveal_items_in_dir}; + +/// Access to the opener APIs, managed by the plugin as app state. +/// +/// Obtain an instance via [`OpenerExt::opener`]. +pub struct Opener { + // we use `fn() -> R` to silence the unused generic error + // while keeping this struct `Send + Sync` without requiring `R` to be + #[cfg(not(mobile))] + _marker: std::marker::PhantomData R>, + #[cfg(mobile)] + mobile_plugin_handle: PluginHandle, + require_literal_leading_dot: Option, +} + +impl Opener { + /// Open a url with a default or specific program. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_opener::OpenerExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// // open the given URL on the system default browser + /// app.opener().open_url("https://github.com/tauri-apps/tauri", None::<&str>)?; + /// Ok(()) + /// }); + /// ``` + /// + /// ## Platform-specific: + /// + /// - **Android / iOS**: Always opens using default program, unless `with` is provided as "inAppBrowser". + #[cfg(desktop)] + pub fn open_url(&self, url: impl Into, with: Option>) -> Result<()> { + crate::open::open( + url.into(), + with.map(Into::into).filter(|with| with != "inAppBrowser"), + ) + } + + /// Open a url with a default or specific program. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_opener::OpenerExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// // open the given URL on the system default browser + /// app.opener().open_url("https://github.com/tauri-apps/tauri", None::<&str>)?; + /// Ok(()) + /// }); + /// ``` + /// + /// ## Platform-specific: + /// + /// - **Android / iOS**: Always opens using default program, unless `with` is provided as "inAppBrowser". + #[cfg(mobile)] + pub fn open_url(&self, url: impl Into, with: Option>) -> Result<()> { + self.mobile_plugin_handle + .run_mobile_plugin( + "open", + serde_json::json!({ "url": url.into(), "with": with.map(Into::into) }), + ) + .map_err(Into::into) + } + + /// Open a path with a default or specific program. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_opener::OpenerExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// // open the given path on the system default explorer + /// app.opener().open_path("/path/to/file", None::<&str>)?; + /// Ok(()) + /// }); + /// ``` + /// + /// ## Platform-specific: + /// + /// - **Android / iOS**: Always opens using default program. + #[cfg(desktop)] + pub fn open_path( + &self, + path: impl Into, + with: Option>, + ) -> Result<()> { + crate::open::open( + path.into(), + with.map(Into::into).filter(|with| with != "inAppBrowser"), + ) + } + + /// Open a path with a default or specific program. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_opener::OpenerExt; + /// + /// tauri::Builder::default() + /// .setup(|app| { + /// // open the given path on the system default explorer + /// app.opener().open_path("/path/to/file", None::<&str>)?; + /// Ok(()) + /// }); + /// ``` + /// + /// ## Platform-specific: + /// + /// - **Android / iOS**: Always opens using default program. + #[cfg(mobile)] + pub fn open_path( + &self, + path: impl Into, + _with: Option>, + ) -> Result<()> { + self.mobile_plugin_handle + .run_mobile_plugin("open", path.into()) + .map_err(Into::into) + } + + /// Reveal a path in the system's default explorer. See [`reveal_item_in_dir`] for details. + /// + /// ## Platform-specific: + /// + /// - **Android / iOS:** Unsupported, returns [`Error::UnsupportedPlatform`]. + pub fn reveal_item_in_dir>(&self, p: P) -> Result<()> { + reveal_item_in_dir(p) + } + + /// Reveal multiple paths in the system's default explorer. See [`reveal_items_in_dir`] for details. + /// + /// ## Platform-specific: + /// + /// - **Android / iOS:** Unsupported, returns [`Error::UnsupportedPlatform`]. + pub fn reveal_items_in_dir(&self, paths: I) -> Result<()> + where + I: IntoIterator, + P: AsRef, + { + reveal_items_in_dir(paths) + } +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the opener APIs. +pub trait OpenerExt { + /// Returns the [`Opener`] instance managed by the plugin. + fn opener(&self) -> &Opener; +} + +impl> OpenerExt for T { + fn opener(&self) -> &Opener { + self.state::>().inner() + } +} + +/// The opener plugin Builder. +pub struct Builder { + open_js_links_on_click: bool, +} + +impl Default for Builder { + fn default() -> Self { + Self { + open_js_links_on_click: true, + } + } +} + +impl Builder { + /// Create a new opener plugin Builder. + pub fn new() -> Self { + Self::default() + } + + /// Whether the plugin should inject a JS script to open URLs in default browser + /// when clicking on `` elements that has `_blank` target, or when pressing `Ctrl` or `Shift` while clicking it. + /// + /// Enabled by default for `http:`, `https:`, `mailto:`, `tel:` links. + pub fn open_js_links_on_click(mut self, open: bool) -> Self { + self.open_js_links_on_click = open; + self + } + + /// Build and Initializes the plugin. + pub fn build(self) -> TauriPlugin> { + let mut builder = tauri::plugin::Builder::>::new("opener") + .setup(|app, api| { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "OpenerPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_opener)?; + + app.manage(Opener { + #[cfg(not(mobile))] + _marker: std::marker::PhantomData:: R>, + #[cfg(mobile)] + mobile_plugin_handle: handle, + require_literal_leading_dot: api + .config() + .as_ref() + .and_then(|c| c.require_literal_leading_dot), + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::open_url, + commands::open_path, + commands::reveal_item_in_dir, + ]); + + if self.open_js_links_on_click { + builder = builder.js_init_script(include_str!("init-iife.js").to_string()); + } + + builder.build() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin> { + Builder::default().build() +} diff --git a/plugins/opener/src/open.rs b/plugins/opener/src/open.rs new file mode 100644 index 0000000..a3d46c5 --- /dev/null +++ b/plugins/opener/src/open.rs @@ -0,0 +1,61 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Types and functions related to shell. + +use std::{ffi::OsStr, path::Path}; + +pub(crate) fn open, S: AsRef>(path: P, with: Option) -> crate::Result<()> { + match with { + Some(program) => ::open::with_detached(path, program.as_ref()), + None => ::open::that_detached(path), + } + .map_err(Into::into) +} + +/// Opens URL with the program specified in `with`, or system default if `None`. +/// +/// ## Platform-specific: +/// +/// - **Android / iOS**: Always opens using default program. +/// +/// # Examples +/// +/// ```rust,no_run +/// tauri::Builder::default() +/// .setup(|app| { +/// // open the given URL on the system default browser +/// tauri_plugin_opener::open_url("https://github.com/tauri-apps/tauri", None::<&str>)?; +/// Ok(()) +/// }); +/// ``` +pub fn open_url, S: AsRef>(url: P, with: Option) -> crate::Result<()> { + let url = url.as_ref(); + open(url, with) +} + +/// Opens path with the program specified in `with`, or system default if `None`. +/// +/// ## Platform-specific: +/// +/// - **Android / iOS**: Always opens using default program. +/// +/// # Examples +/// +/// ```rust,no_run +/// tauri::Builder::default() +/// .setup(|app| { +/// // open the given URL on the system default explorer +/// tauri_plugin_opener::open_path("/path/to/file", None::<&str>)?; +/// Ok(()) +/// }); +/// ``` +pub fn open_path, S: AsRef>(path: P, with: Option) -> crate::Result<()> { + let path = path.as_ref(); + if with.is_none() { + // Returns an IO error if not exists, and besides `exists()` is a shorthand for `metadata()` + _ = path.metadata()?; + } + open(path, with) +} diff --git a/plugins/opener/src/reveal_item_in_dir.rs b/plugins/opener/src/reveal_item_in_dir.rs new file mode 100644 index 0000000..9578a28 --- /dev/null +++ b/plugins/opener/src/reveal_item_in_dir.rs @@ -0,0 +1,311 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::{Path, PathBuf}; + +/// Reveal a path in the system's default explorer. +/// +/// ## Platform-specific: +/// +/// - **Android / iOS:** Unsupported. +pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { + let path = canonicalize(path.as_ref())?; + + #[cfg(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + return imp::reveal_items_in_dir(&[path]); + + #[cfg(not(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] + Err(crate::Error::UnsupportedPlatform) +} + +/// Reveal multiple paths in the system's default explorer. +/// +/// ## Platform-specific: +/// +/// - **Android / iOS:** Unsupported. +pub fn reveal_items_in_dir(paths: I) -> crate::Result<()> +where + I: IntoIterator, + P: AsRef, +{ + let mut canonicalized = vec![]; + + for path in paths { + let path = canonicalize(path.as_ref())?; + canonicalized.push(path); + } + + #[cfg(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + return imp::reveal_items_in_dir(&canonicalized); + + #[cfg(not(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] + Err(crate::Error::UnsupportedPlatform) +} + +fn canonicalize(path: &Path) -> crate::Result { + #[cfg(windows)] + let path = crate::windows_shell_path::absolute_and_check_exists(dunce::simplified(path))?; + #[cfg(not(windows))] + let path = std::fs::canonicalize(path)?; + Ok(path) +} + +#[cfg(windows)] +mod imp { + use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, + }; + + use windows::Win32::UI::Shell::Common::ITEMIDLIST; + use windows::{ + core::{w, HSTRING, PCWSTR}, + Win32::{ + Foundation::ERROR_FILE_NOT_FOUND, + System::Com::CoInitialize, + UI::{ + Shell::{ + ILCreateFromPathW, ILFree, SHOpenFolderAndSelectItems, ShellExecuteExW, + SHELLEXECUTEINFOW, + }, + WindowsAndMessaging::SW_SHOWNORMAL, + }, + }, + }; + + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { + if paths.is_empty() { + return Ok(()); + } + + let mut grouped_paths: HashMap, Vec<&Path>> = HashMap::new(); + for path in paths { + let parent = crate::windows_shell_path::shell_parent_path(path) + .ok_or_else(|| crate::Error::NoParent(path.to_path_buf()))?; + grouped_paths.entry(parent).or_default().push(path); + } + + let _ = unsafe { CoInitialize(None) }; + + for (parent, to_reveals) in grouped_paths { + let parent_item_id_list = OwnedItemIdList::new(&parent)?; + let to_reveals_item_id_list = to_reveals + .iter() + .map(|to_reveal| OwnedItemIdList::new(to_reveal)) + .collect::>>()?; + if let Err(e) = unsafe { + SHOpenFolderAndSelectItems( + parent_item_id_list.item, + Some( + &to_reveals_item_id_list + .iter() + .map(|item| item.item) + .collect::>(), + ), + 0, + ) + } { + // from https://github.com/electron/electron/blob/10d967028af2e72382d16b7e2025d243b9e204ae/shell/common/platform_util_win.cc#L302 + // On some systems, the above call mysteriously fails with "file not + // found" even though the file is there. In these cases, ShellExecute() + // seems to work as a fallback (although it won't select the file). + // + // Note: we only handle the first file here if multiple of are present + if e.code().0 == ERROR_FILE_NOT_FOUND.0 as i32 { + let first_path = to_reveals[0]; + let is_dir = first_path.is_dir(); + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as _, + nShow: SW_SHOWNORMAL.0, + lpFile: PCWSTR(parent_item_id_list.hstring.as_ptr()), + lpClass: if is_dir { w!("folder") } else { PCWSTR::null() }, + lpVerb: if is_dir { + w!("explore") + } else { + PCWSTR::null() + }, + ..Default::default() + }; + + unsafe { ShellExecuteExW(&mut info) }?; + } + } + } + + Ok(()) + } + + struct OwnedItemIdList { + hstring: HSTRING, + item: *const ITEMIDLIST, + } + + impl OwnedItemIdList { + fn new(path: &Path) -> crate::Result { + let path_hstring = HSTRING::from(path); + let item_id_list = unsafe { ILCreateFromPathW(&path_hstring) }; + if item_id_list.is_null() { + Err(crate::Error::FailedToConvertPathToItemIdList( + path.to_owned(), + )) + } else { + Ok(Self { + hstring: path_hstring, + item: item_id_list, + }) + } + } + } + + impl Drop for OwnedItemIdList { + fn drop(&mut self) { + if !self.item.is_null() { + unsafe { ILFree(Some(self.item)) }; + } + } + } +} + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +mod imp { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { + let connection = zbus::blocking::Connection::session()?; + + reveal_with_filemanager1(paths, &connection).or_else(|e| { + // Fallback to opening the directory of the first item if revealing multiple items fails. + if let Some(first_path) = paths.first() { + reveal_with_open_uri_portal(first_path, &connection) + } else { + Err(e) + } + }) + } + + fn reveal_with_filemanager1( + paths: &[PathBuf], + connection: &zbus::blocking::Connection, + ) -> crate::Result<()> { + let uris: Result, _> = paths + .iter() + .map(|path| { + url::Url::from_file_path(path) + .map_err(|_| crate::Error::FailedToConvertPathToFileUrl) + }) + .collect(); + let uris = uris?; + let uri_strs: Vec<&str> = uris.iter().map(|uri| uri.as_str()).collect(); + + #[zbus::proxy( + interface = "org.freedesktop.FileManager1", + default_service = "org.freedesktop.FileManager1", + default_path = "/org/freedesktop/FileManager1" + )] + trait FileManager1 { + async fn ShowItems(&self, name: Vec<&str>, arg2: &str) -> crate::Result<()>; + } + + let proxy = FileManager1ProxyBlocking::new(connection)?; + + proxy.ShowItems(uri_strs, "") + } + + fn reveal_with_open_uri_portal( + path: &Path, + connection: &zbus::blocking::Connection, + ) -> crate::Result<()> { + let uri = url::Url::from_file_path(path) + .map_err(|_| crate::Error::FailedToConvertPathToFileUrl)?; + + #[zbus::proxy( + interface = "org.freedesktop.portal.Desktop", + default_service = "org.freedesktop.portal.OpenURI", + default_path = "/org/freedesktop/portal/desktop" + )] + trait PortalDesktop { + async fn OpenDirectory( + &self, + arg1: &str, + name: &str, + arg3: HashMap<&str, &str>, + ) -> crate::Result<()>; + } + + let proxy = PortalDesktopProxyBlocking::new(connection)?; + + proxy.OpenDirectory("", uri.as_str(), HashMap::new()) + } +} + +#[cfg(target_os = "macos")] +mod imp { + use objc2_app_kit::NSWorkspace; + use objc2_foundation::{NSArray, NSString, NSURL}; + use std::path::PathBuf; + + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { + // these AppKit calls are only `unsafe` before objc2-app-kit 0.3.2 + #[allow(unused_unsafe)] + unsafe { + let mut urls = Vec::new(); + + for path in paths { + let path = path.to_string_lossy(); + let path = NSString::from_str(&path); + let url = NSURL::fileURLWithPath(&path); + + urls.push(url); + } + + let urls = NSArray::from_retained_slice(&urls); + + let workspace = NSWorkspace::new(); + workspace.activateFileViewerSelectingURLs(&urls); + } + + Ok(()) + } +} diff --git a/plugins/opener/src/scope.rs b/plugins/opener/src/scope.rs new file mode 100644 index 0000000..275913d --- /dev/null +++ b/plugins/opener/src/scope.rs @@ -0,0 +1,141 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + marker::PhantomData, + path::{Path, PathBuf}, + sync::Arc, +}; + +use tauri::{ipc::ScopeObject, utils::acl::Value, AppHandle, Manager, Runtime}; + +use crate::{scope_entry::EntryRaw, Error}; + +pub use crate::scope_entry::Application; + +#[derive(Debug)] +pub enum Entry { + Url { + url: glob::Pattern, + app: Application, + }, + Path { + path: Option, + app: Application, + }, +} + +impl ScopeObject for Entry { + type Error = Error; + + fn deserialize( + app_handle: &AppHandle, + raw: Value, + ) -> std::result::Result { + serde_json::from_value(raw.into()) + .and_then(|raw| { + let entry = match raw { + EntryRaw::Url { url, app } => Entry::Url { + url: glob::Pattern::new(&url) + .map_err(|e| serde::de::Error::custom(e.to_string()))?, + app, + }, + EntryRaw::Path { path, app } => { + let path = match app_handle.path().parse(path) { + Ok(path) => Some(path), + #[cfg(not(target_os = "android"))] + Err(tauri::Error::UnknownPath) => None, + Err(err) => return Err(serde::de::Error::custom(err.to_string())), + }; + + Entry::Path { path, app } + } + }; + + Ok(entry) + }) + .map_err(Into::into) + } +} + +impl Application { + fn matches(&self, a: Option<&str>) -> bool { + match self { + Self::Default => a.is_none(), + Self::Enable(enable) => *enable, + Self::App(program) => Some(program.as_str()) == a, + } + } +} + +impl Entry { + fn path(&self) -> Option { + match self { + Self::Url { .. } => None, + Self::Path { path, .. } => path.clone(), + } + } + + fn matches_url(&self, u: &str, a: Option<&str>) -> bool { + match self { + Self::Url { url, app } => url.matches(u) && app.matches(a), + Self::Path { .. } => false, + } + } + + fn matches_path_program(&self, a: Option<&str>) -> bool { + match self { + Self::Url { .. } => false, + Self::Path { app, .. } => app.matches(a), + } + } +} + +#[derive(Debug)] +pub struct Scope<'a, R: Runtime, M: Manager> { + allowed: Vec<&'a Arc>, + denied: Vec<&'a Arc>, + manager: &'a M, + _marker: PhantomData, +} + +impl<'a, R: Runtime, M: Manager> Scope<'a, R, M> { + pub(crate) fn new( + manager: &'a M, + allowed: Vec<&'a Arc>, + denied: Vec<&'a Arc>, + ) -> Self { + Self { + manager, + allowed, + denied, + _marker: PhantomData, + } + } + + pub fn is_url_allowed(&self, url: &str, with: Option<&str>) -> bool { + let denied = self.denied.iter().any(|e| e.matches_url(url, with)); + if denied { + false + } else { + self.allowed.iter().any(|e| e.matches_url(url, with)) + } + } + + pub fn is_path_allowed(&self, path: &Path, with: Option<&str>) -> crate::Result { + let fs_scope = tauri::fs::Scope::new( + self.manager, + &tauri::utils::config::FsScope::Scope { + allow: self.allowed.iter().filter_map(|e| e.path()).collect(), + deny: self.denied.iter().filter_map(|e| e.path()).collect(), + require_literal_leading_dot: self + .manager + .state::>() + .require_literal_leading_dot, + }, + )?; + + Ok(fs_scope.is_allowed(path) && self.allowed.iter().any(|e| e.matches_path_program(with))) + } +} diff --git a/plugins/opener/src/scope_entry.rs b/plugins/opener/src/scope_entry.rs new file mode 100644 index 0000000..d4867b4 --- /dev/null +++ b/plugins/opener/src/scope_entry.rs @@ -0,0 +1,32 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +#[derive(Default)] +pub enum Application { + #[default] + Default, + Enable(bool), + App(String), +} + +#[derive(Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +pub(crate) enum EntryRaw { + Url { + url: String, + #[serde(default)] + app: Application, + }, + Path { + path: PathBuf, + #[serde(default)] + app: Application, + }, +} diff --git a/plugins/opener/src/windows_shell_path.rs b/plugins/opener/src/windows_shell_path.rs new file mode 100644 index 0000000..8715641 --- /dev/null +++ b/plugins/opener/src/windows_shell_path.rs @@ -0,0 +1,255 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + borrow::Cow, + ffi::OsString, + io, + os::windows::ffi::OsStringExt, + path::{Component, Path, PathBuf, Prefix, PrefixComponent}, +}; + +use windows::{core::HSTRING, Win32::Storage::FileSystem::GetFullPathNameW}; + +pub fn absolute_and_check_exists(path: &Path) -> io::Result { + let path = absolute(path)?; + if path.exists() { + Ok(path) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "path doesn't exist", + )) + } +} + +// TODO: Switch to use `std::path::absolute` once MSRV > 1.79 +// Modified from https://github.com/rust-lang/rust/blob/b49ecc9eb70a51e89f32a7358e790f7b3808ccb3/library/std/src/sys/path/windows.rs#L185 +// Note: this doesn't resolve symlinks +fn absolute(path: &Path) -> io::Result { + if path.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot make an empty path absolute", + )); + } + + let prefix = path.components().next(); + // Verbatim paths should not be modified. + if prefix + .map(|component| { + let Component::Prefix(prefix) = component else { + return false; + }; + matches!( + prefix.kind(), + Prefix::Verbatim(..) | Prefix::VerbatimDisk(..) | Prefix::VerbatimUNC(..) + ) + }) + .unwrap_or(false) + { + // NULs in verbatim paths are rejected for consistency. + if path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "strings passed to WinAPI cannot contain NULs", + )); + } + return Ok(path.to_owned()); + } + + // This is an additional check to make sure we don't pass in a single driver letter to GetFullPathNameW + // which will resolves to the current working directory + // + // > https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfullpathnamew#:~:text=If%20you%20specify%20%22U%3A%22%20the%20path%20returned%20is%20the%20current%20directory%20on%20the%20%22U%3A%5C%22%20drive + #[allow(clippy::collapsible_if)] + if let Some(Component::Prefix(last_prefix)) = path.components().next_back() { + if matches!(last_prefix.kind(), Prefix::Disk(..)) { + return Ok(PathBuf::from(last_prefix.as_os_str())); + } + } + + let path_hstring = HSTRING::from(path); + + let size = unsafe { GetFullPathNameW(&path_hstring, None, None) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0; size as usize]; + let size = unsafe { GetFullPathNameW(&path_hstring, Some(&mut buffer), None) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(PathBuf::from(OsString::from_wide(&buffer[..size as usize]))) +} + +/// Similar to [`Path::parent`] but resolves parent of `C:`/`C:\` to `""` and handles UNC host name (`\\wsl.localhost\Ubuntu\` to `\\wsl.localhost`) +pub fn shell_parent_path(path: &Path) -> Option> { + fn handle_prefix(prefix: PrefixComponent<'_>) -> Option> { + match prefix.kind() { + Prefix::UNC(host_name, _share_name) => { + let mut path = OsString::from(r"\\"); + path.push(host_name); + Some(PathBuf::from(path).into()) + } + Prefix::Disk(_) => Some(PathBuf::from("").into()), + _ => None, + } + } + + let mut components = path.components(); + let component = components.next_back()?; + match component { + Component::Normal(_) | Component::CurDir | Component::ParentDir => { + Some(components.as_path().into()) + } + Component::Prefix(prefix) => handle_prefix(prefix), + // Handle cases like `C:\` and `\\wsl.localhost\Ubuntu\` + Component::RootDir => { + if let Component::Prefix(prefix) = components.next_back()? { + handle_prefix(prefix) + } else { + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + // absolute() tests + + #[test] + fn absolute_empty_error() { + let err = absolute(Path::new("")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn absolute_verbatim_passthrough() { + let path = Path::new(r"\\?\C:\foo"); + assert_eq!(absolute(path).unwrap(), path); + } + + #[test] + fn absolute_verbatim_unc_passthrough() { + let path = Path::new(r"\\?\UNC\server\share"); + assert_eq!(absolute(path).unwrap(), path); + } + + #[test] + fn absolute_bare_drive_letter() { + let result = absolute(Path::new("C:")).unwrap(); + assert_eq!(result, Path::new("C:")); + } + + #[test] + fn absolute_already_absolute() { + let result = absolute(Path::new(r"C:\Windows")).unwrap(); + assert_eq!(result, Path::new(r"C:\Windows")); + } + + #[test] + fn absolute_unc_path() { + let result = absolute(Path::new(r"\\server\share\folder")).unwrap(); + assert_eq!(result, Path::new(r"\\server\share\folder")); + } + + #[test] + fn absolute_converts_forward_slashes() { + let result = absolute(Path::new("C:/Windows/System32")).unwrap(); + assert_eq!(result, Path::new(r"C:\Windows\System32")); + } + + // absolute_and_check_exists() tests + + #[test] + fn absolute_and_check_exists_existing_path() { + assert!(absolute_and_check_exists(Path::new(r"C:\Windows")).is_ok()); + } + + #[test] + fn absolute_and_check_exists_nonexistent_path() { + let err = absolute_and_check_exists(Path::new(r"C:\nonexistent_xyz_12345")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn absolute_and_check_exists_empty_propagates() { + let err = absolute_and_check_exists(Path::new("")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + // shell_parent_path() tests + + #[test] + fn shell_parent_path_local_path() { + let result = shell_parent_path(Path::new(r"C:\Users\foo")); + assert_eq!(result.as_deref(), Some(Path::new(r"C:\Users"))); + } + + #[test] + fn shell_parent_path_nested_path() { + let result = shell_parent_path(Path::new(r"C:\a\b\c\d")); + assert_eq!(result.as_deref(), Some(Path::new(r"C:\a\b\c"))); + } + + #[test] + fn shell_parent_path_drive_root_trailing() { + let result = shell_parent_path(Path::new(r"C:\")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_bare_drive() { + let result = shell_parent_path(Path::new("C:")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_unc_with_subfolder() { + let result = shell_parent_path(Path::new(r"\\server\share\folder")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server\share"))); + } + + #[test] + fn shell_parent_path_unc_share_trailing_slash() { + let result = shell_parent_path(Path::new(r"\\server.local\share\")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server.local"))); + } + + #[test] + fn shell_parent_path_unc_share_no_slash() { + let result = shell_parent_path(Path::new(r"\\server\share")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\server"))); + } + + #[test] + fn shell_parent_path_relative() { + let result = shell_parent_path(Path::new(r"foo\bar")); + assert_eq!(result.as_deref(), Some(Path::new("foo"))); + } + + #[test] + fn shell_parent_path_single_component() { + let result = shell_parent_path(Path::new("foo")); + assert_eq!(result.as_deref(), Some(Path::new(""))); + } + + #[test] + fn shell_parent_path_empty() { + let result = shell_parent_path(Path::new("")); + assert!(result.is_none()); + } + + #[test] + fn shell_parent_path_verbatim() { + let result = shell_parent_path(Path::new(r"\\?\C:\foo")); + assert_eq!(result.as_deref(), Some(Path::new(r"\\?\C:\"))); + } +} diff --git a/plugins/opener/tsconfig.json b/plugins/opener/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/opener/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/os/CHANGELOG.md b/plugins/os/CHANGELOG.md new file mode 100644 index 0000000..603b243 --- /dev/null +++ b/plugins/os/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +## \[2.3.2] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.1] + +- [`d3d290ab`](https://github.com/tauri-apps/plugins-workspace/commit/d3d290ab8a8913981a98e2eb7f2c5d4aba3bc36c) ([#2912](https://github.com/tauri-apps/plugins-workspace/pull/2912) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Unlocked version of `serialize-to-javascript` from `=0.1.1` to `^0.1.1` for compatibility with Tauri's upcoming version `2.8`. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.2] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.1] + +- [`a1b3fa27`](https://github.com/tauri-apps/plugins-workspace/commit/a1b3fa27f11022c9b6622b4fab12d93239eb05de) ([#2515](https://github.com/tauri-apps/plugins-workspace/pull/2515) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Re-exported the `Geolocation`, `Haptics`, `Notification`, and `Os` structs so that they show up on docs.rs. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`40ef9a81`](https://github.com/tauri-apps/plugins-workspace/commit/40ef9a818fb03457819c1d72ea84de57fbf868ba) ([#1514](https://github.com/tauri-apps/plugins-workspace/pull/1514) by [@fynntang](https://github.com/tauri-apps/plugins-workspace/../../fynntang)) **Changed:** `platform`, `arch`, `type`, `family`, `version` and `exe_extension` functions in the documentation examples to better reflect that these functions are synchronous. +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`0959fe37`](https://github.com/tauri-apps/plugins-workspace/commit/0959fe3757250c6dea6247edb20e6ab468f20511) ([#1353](https://github.com/tauri-apps/plugins-workspace/pull/1353) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) **Breaking** Changed `platform`, `arch`, `type`, `family`, `version` and `exe_extension` functions to be sync. +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.6] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. +- [`fc62ead`](https://github.com/tauri-apps/plugins-workspace/commit/fc62ead56515b64138b8342af1c5ec6071b715fc)([#721](https://github.com/tauri-apps/plugins-workspace/pull/721)) Fix `Uncaught TypeError: Cannot read properties of undefined (reading 'os')` + +## \[2.0.0-alpha.4] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.3] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.2] + +- [`e510f2f`](https://github.com/tauri-apps/plugins-workspace/commit/e510f2fe4c227c107a1faca9386b5ceb326611ed)([#561](https://github.com/tauri-apps/plugins-workspace/pull/561)) Fix `macss -> macos` typo in `OsType` type. + +## \[2.0.0-alpha.1] + +- [`1091d6d`](https://github.com/tauri-apps/plugins-workspace/commit/1091d6d6ac5081f2c7526b0f492ae4f34b306f1d)([#419](https://github.com/tauri-apps/plugins-workspace/pull/419)) The os plugin is recieving a few changes to improve consistency and add new features: + + - Renamed `Kind` enum to `OsType` and `kind()` function to `os_type()`. + - Added `family()`,`exe_extension()`, and `hostname()` functions and their equivalents for JS. + - Removed `tempdir()` function and its equivalent on JS, use `std::env::temp_dir` instead of `temp_dir` from `tauri::path::PathResolver::temp_dir` and `path.tempDir` on JS. + - Modified `platform()` implementation to return `windows` instead of `win32` and `macos` instead of `darwin` to align with Rust's `std::env::consts::OS` + - `EOL` const in JS has been modified into a function `eol()` fix import issues in frameworks like `next.js` +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/os/Cargo.toml b/plugins/os/Cargo.toml new file mode 100644 index 0000000..c4b3e5a --- /dev/null +++ b/plugins/os/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "tauri-plugin-os" +version = "2.3.2" +description = "Read information about the operating system." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-os" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +os_info = "3" +sys-locale = "0.3" +gethostname = "1.0" +serialize-to-javascript = "0.1.1" diff --git a/plugins/os/LICENSE.spdx b/plugins/os/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/os/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/os/LICENSE_APACHE-2.0 b/plugins/os/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/os/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/os/LICENSE_MIT b/plugins/os/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/os/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/os/README.md b/plugins/os/README.md new file mode 100644 index 0000000..d3f58b2 --- /dev/null +++ b/plugins/os/README.md @@ -0,0 +1,90 @@ +![plugin-os](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/os/banner.png) + +Read information about the operating system. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-os = "2.0.0" +# alternatively with Git: +tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-os +# or +npm add @tauri-apps/plugin-os +# or +yarn add @tauri-apps/plugin-os +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_os::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { version } from '@tauri-apps/plugin-os' +const osVersion = await version() +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/os/SECURITY.md b/plugins/os/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/os/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/os/api-iife.js b/plugins/os/api-iife.js new file mode 100644 index 0000000..48b41cc --- /dev/null +++ b/plugins/os/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_OS__=function(_,n){"use strict";return _.arch=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.arch},_.eol=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.eol},_.exeExtension=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.exe_extension},_.family=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.family},_.hostname=async function(){return await n.invoke("plugin:os|hostname")},_.locale=async function(){return await n.invoke("plugin:os|locale")},_.platform=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.platform},_.type=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.os_type},_.version=function(){return window.__TAURI_OS_PLUGIN_INTERNALS__.version},_}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"os",{value:__TAURI_PLUGIN_OS__})} diff --git a/plugins/os/banner.png b/plugins/os/banner.png new file mode 100644 index 0000000..08b4fd8 Binary files /dev/null and b/plugins/os/banner.png differ diff --git a/plugins/os/build.rs b/plugins/os/build.rs new file mode 100644 index 0000000..f108f96 --- /dev/null +++ b/plugins/os/build.rs @@ -0,0 +1,20 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &[ + "platform", + "version", + "os_type", + "family", + "arch", + "exe_extension", + "locale", + "hostname", +]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); +} diff --git a/plugins/os/guest-js/index.ts b/plugins/os/guest-js/index.ts new file mode 100644 index 0000000..21a5777 --- /dev/null +++ b/plugins/os/guest-js/index.ts @@ -0,0 +1,225 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Provides operating system-related utility methods and properties. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** @ignore */ +declare global { + interface Window { + __TAURI_OS_PLUGIN_INTERNALS__: { + eol: string + os_type: OsType + platform: Platform + family: Family + version: string + arch: Arch + exe_extension: string + } + } +} + +/** + * A string describing the specific operating system in use, as returned by {@link platform}. + */ +type Platform = + | 'linux' + | 'macos' + | 'ios' + | 'freebsd' + | 'dragonfly' + | 'netbsd' + | 'openbsd' + | 'solaris' + | 'android' + | 'windows' + +/** + * A coarse-grained operating system category, as returned by {@link type}. + */ +type OsType = 'linux' | 'windows' | 'macos' | 'ios' | 'android' + +/** + * A string describing the specific operating system architecture in use, as returned by {@link arch}. + */ +type Arch = + | 'x86' + | 'x86_64' + | 'arm' + | 'aarch64' + | 'mips' + | 'mips64' + | 'powerpc' + | 'powerpc64' + | 'riscv64' + | 's390x' + | 'sparc64' + +/** + * Returns the operating system-specific end-of-line marker. + * - `\n` on POSIX + * - `\r\n` on Windows + * + * @example + * ```typescript + * import { eol } from '@tauri-apps/plugin-os'; + * const eolChar = eol(); + * ``` + * + * @returns The end-of-line marker for the current platform. + * @since 2.0.0 + * */ +function eol(): string { + return window.__TAURI_OS_PLUGIN_INTERNALS__.eol +} + +/** + * Returns a string describing the specific operating system in use. + * The value is set at compile time. Possible values are `'linux'`, `'macos'`, `'ios'`, `'freebsd'`, `'dragonfly'`, `'netbsd'`, `'openbsd'`, `'solaris'`, `'android'`, `'windows'` + * + * @example + * ```typescript + * import { platform } from '@tauri-apps/plugin-os'; + * const platformName = platform(); + * ``` + * + * @returns The platform name. + * @since 2.0.0 + * + */ +function platform(): Platform { + return window.__TAURI_OS_PLUGIN_INTERNALS__.platform +} + +/** + * Returns the current operating system version. + * @example + * ```typescript + * import { version } from '@tauri-apps/plugin-os'; + * const osVersion = version(); + * ``` + * + * @returns The operating system version. + * @since 2.0.0 + */ +function version(): string { + return window.__TAURI_OS_PLUGIN_INTERNALS__.version +} + +/** + * A string describing the operating system family, as returned by {@link family}. + */ +type Family = 'unix' | 'windows' + +/** + * Returns the current operating system family. Possible values are `'unix'`, `'windows'`. + * @example + * ```typescript + * import { family } from '@tauri-apps/plugin-os'; + * const family = family(); + * ``` + * + * @returns The operating system family. + * @since 2.0.0 + */ +function family(): Family { + return window.__TAURI_OS_PLUGIN_INTERNALS__.family +} + +/** + * Returns the current operating system type. Returns `'linux'` on Linux, `'macos'` on macOS, `'windows'` on Windows, `'ios'` on iOS and `'android'` on Android. + * @example + * ```typescript + * import { type } from '@tauri-apps/plugin-os'; + * const osType = type(); + * ``` + * + * @returns The operating system type. + * @since 2.0.0 + */ +function type(): OsType { + return window.__TAURI_OS_PLUGIN_INTERNALS__.os_type +} + +/** + * Returns the current operating system architecture. + * Possible values are `'x86'`, `'x86_64'`, `'arm'`, `'aarch64'`, `'mips'`, `'mips64'`, `'powerpc'`, `'powerpc64'`, `'riscv64'`, `'s390x'`, `'sparc64'`. + * @example + * ```typescript + * import { arch } from '@tauri-apps/plugin-os'; + * const archName = arch(); + * ``` + * + * @returns The operating system architecture. + * @since 2.0.0 + */ +function arch(): Arch { + return window.__TAURI_OS_PLUGIN_INTERNALS__.arch +} + +/** + * Returns the file extension, if any, used for executable binaries on this platform. Possible values are `'exe'` and `''` (empty string). + * @example + * ```typescript + * import { exeExtension } from '@tauri-apps/plugin-os'; + * const exeExt = exeExtension(); + * ``` + * + * @returns The file extension used for executable binaries on this platform. + * @since 2.0.0 + */ +function exeExtension(): string { + return window.__TAURI_OS_PLUGIN_INTERNALS__.exe_extension +} + +/** + * Returns a String with a `BCP-47` language tag inside. If the locale couldn’t be obtained, `null` is returned instead. + * @example + * ```typescript + * import { locale } from '@tauri-apps/plugin-os'; + * const locale = await locale(); + * if (locale) { + * // use the locale string here + * } + * ``` + * + * @returns A promise resolving to the `BCP-47` language tag, or `null` if it could not be obtained. + * @since 2.0.0 + */ +async function locale(): Promise { + return await invoke('plugin:os|locale') +} + +/** + * Returns the host name of the operating system. + * @example + * ```typescript + * import { hostname } from '@tauri-apps/plugin-os'; + * const hostname = await hostname(); + * ``` + * + * @returns A promise resolving to the host name of the operating system. + * @since 2.0.0 + */ +async function hostname(): Promise { + return await invoke('plugin:os|hostname') +} + +export { + eol, + platform, + family, + version, + type, + arch, + locale, + exeExtension, + hostname +} +export type { Platform, OsType, Arch, Family } diff --git a/plugins/os/package.json b/plugins/os/package.json new file mode 100644 index 0000000..dbaef8e --- /dev/null +++ b/plugins/os/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-os", + "version": "2.3.2", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/os/permissions/autogenerated/commands/arch.toml b/plugins/os/permissions/autogenerated/commands/arch.toml new file mode 100644 index 0000000..160cce0 --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/arch.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-arch" +description = "Enables the arch command without any pre-configured scope." +commands.allow = ["arch"] + +[[permission]] +identifier = "deny-arch" +description = "Denies the arch command without any pre-configured scope." +commands.deny = ["arch"] diff --git a/plugins/os/permissions/autogenerated/commands/exe_extension.toml b/plugins/os/permissions/autogenerated/commands/exe_extension.toml new file mode 100644 index 0000000..aef4d2e --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/exe_extension.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-exe-extension" +description = "Enables the exe_extension command without any pre-configured scope." +commands.allow = ["exe_extension"] + +[[permission]] +identifier = "deny-exe-extension" +description = "Denies the exe_extension command without any pre-configured scope." +commands.deny = ["exe_extension"] diff --git a/plugins/os/permissions/autogenerated/commands/family.toml b/plugins/os/permissions/autogenerated/commands/family.toml new file mode 100644 index 0000000..4109be3 --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/family.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-family" +description = "Enables the family command without any pre-configured scope." +commands.allow = ["family"] + +[[permission]] +identifier = "deny-family" +description = "Denies the family command without any pre-configured scope." +commands.deny = ["family"] diff --git a/plugins/os/permissions/autogenerated/commands/hostname.toml b/plugins/os/permissions/autogenerated/commands/hostname.toml new file mode 100644 index 0000000..c65376c --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/hostname.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-hostname" +description = "Enables the hostname command without any pre-configured scope." +commands.allow = ["hostname"] + +[[permission]] +identifier = "deny-hostname" +description = "Denies the hostname command without any pre-configured scope." +commands.deny = ["hostname"] diff --git a/plugins/os/permissions/autogenerated/commands/locale.toml b/plugins/os/permissions/autogenerated/commands/locale.toml new file mode 100644 index 0000000..2a85e47 --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/locale.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-locale" +description = "Enables the locale command without any pre-configured scope." +commands.allow = ["locale"] + +[[permission]] +identifier = "deny-locale" +description = "Denies the locale command without any pre-configured scope." +commands.deny = ["locale"] diff --git a/plugins/os/permissions/autogenerated/commands/os_type.toml b/plugins/os/permissions/autogenerated/commands/os_type.toml new file mode 100644 index 0000000..c91987a --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/os_type.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-os-type" +description = "Enables the os_type command without any pre-configured scope." +commands.allow = ["os_type"] + +[[permission]] +identifier = "deny-os-type" +description = "Denies the os_type command without any pre-configured scope." +commands.deny = ["os_type"] diff --git a/plugins/os/permissions/autogenerated/commands/platform.toml b/plugins/os/permissions/autogenerated/commands/platform.toml new file mode 100644 index 0000000..49dc08d --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/platform.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-platform" +description = "Enables the platform command without any pre-configured scope." +commands.allow = ["platform"] + +[[permission]] +identifier = "deny-platform" +description = "Denies the platform command without any pre-configured scope." +commands.deny = ["platform"] diff --git a/plugins/os/permissions/autogenerated/commands/version.toml b/plugins/os/permissions/autogenerated/commands/version.toml new file mode 100644 index 0000000..2d65e32 --- /dev/null +++ b/plugins/os/permissions/autogenerated/commands/version.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-version" +description = "Enables the version command without any pre-configured scope." +commands.allow = ["version"] + +[[permission]] +identifier = "deny-version" +description = "Denies the version command without any pre-configured scope." +commands.deny = ["version"] diff --git a/plugins/os/permissions/autogenerated/reference.md b/plugins/os/permissions/autogenerated/reference.md new file mode 100644 index 0000000..7ac7375 --- /dev/null +++ b/plugins/os/permissions/autogenerated/reference.md @@ -0,0 +1,237 @@ +## Default Permission + +This permission set configures which +operating system information are available +to gather from the frontend. + +#### Granted Permissions + +All information except the host name are available. + +#### This default permission set includes the following: + +- `allow-arch` +- `allow-exe-extension` +- `allow-family` +- `allow-locale` +- `allow-os-type` +- `allow-platform` +- `allow-version` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`os:allow-arch` + + + +Enables the arch command without any pre-configured scope. + +
+ +`os:deny-arch` + + + +Denies the arch command without any pre-configured scope. + +
+ +`os:allow-exe-extension` + + + +Enables the exe_extension command without any pre-configured scope. + +
+ +`os:deny-exe-extension` + + + +Denies the exe_extension command without any pre-configured scope. + +
+ +`os:allow-family` + + + +Enables the family command without any pre-configured scope. + +
+ +`os:deny-family` + + + +Denies the family command without any pre-configured scope. + +
+ +`os:allow-hostname` + + + +Enables the hostname command without any pre-configured scope. + +
+ +`os:deny-hostname` + + + +Denies the hostname command without any pre-configured scope. + +
+ +`os:allow-locale` + + + +Enables the locale command without any pre-configured scope. + +
+ +`os:deny-locale` + + + +Denies the locale command without any pre-configured scope. + +
+ +`os:allow-os-type` + + + +Enables the os_type command without any pre-configured scope. + +
+ +`os:deny-os-type` + + + +Denies the os_type command without any pre-configured scope. + +
+ +`os:allow-platform` + + + +Enables the platform command without any pre-configured scope. + +
+ +`os:deny-platform` + + + +Denies the platform command without any pre-configured scope. + +
+ +`os:allow-version` + + + +Enables the version command without any pre-configured scope. + +
+ +`os:deny-version` + + + +Denies the version command without any pre-configured scope. + +
diff --git a/plugins/os/permissions/default.toml b/plugins/os/permissions/default.toml new file mode 100644 index 0000000..217b389 --- /dev/null +++ b/plugins/os/permissions/default.toml @@ -0,0 +1,23 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures which +operating system information are available +to gather from the frontend. + +#### Granted Permissions + +All information except the host name are available. + +""" + +permissions = [ + "allow-arch", + "allow-exe-extension", + "allow-family", + "allow-locale", + "allow-os-type", + "allow-platform", + "allow-version", +] diff --git a/plugins/os/permissions/schemas/schema.json b/plugins/os/permissions/schemas/schema.json new file mode 100644 index 0000000..36680b4 --- /dev/null +++ b/plugins/os/permissions/schemas/schema.json @@ -0,0 +1,402 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the arch command without any pre-configured scope.", + "type": "string", + "const": "allow-arch", + "markdownDescription": "Enables the arch command without any pre-configured scope." + }, + { + "description": "Denies the arch command without any pre-configured scope.", + "type": "string", + "const": "deny-arch", + "markdownDescription": "Denies the arch command without any pre-configured scope." + }, + { + "description": "Enables the exe_extension command without any pre-configured scope.", + "type": "string", + "const": "allow-exe-extension", + "markdownDescription": "Enables the exe_extension command without any pre-configured scope." + }, + { + "description": "Denies the exe_extension command without any pre-configured scope.", + "type": "string", + "const": "deny-exe-extension", + "markdownDescription": "Denies the exe_extension command without any pre-configured scope." + }, + { + "description": "Enables the family command without any pre-configured scope.", + "type": "string", + "const": "allow-family", + "markdownDescription": "Enables the family command without any pre-configured scope." + }, + { + "description": "Denies the family command without any pre-configured scope.", + "type": "string", + "const": "deny-family", + "markdownDescription": "Denies the family command without any pre-configured scope." + }, + { + "description": "Enables the hostname command without any pre-configured scope.", + "type": "string", + "const": "allow-hostname", + "markdownDescription": "Enables the hostname command without any pre-configured scope." + }, + { + "description": "Denies the hostname command without any pre-configured scope.", + "type": "string", + "const": "deny-hostname", + "markdownDescription": "Denies the hostname command without any pre-configured scope." + }, + { + "description": "Enables the locale command without any pre-configured scope.", + "type": "string", + "const": "allow-locale", + "markdownDescription": "Enables the locale command without any pre-configured scope." + }, + { + "description": "Denies the locale command without any pre-configured scope.", + "type": "string", + "const": "deny-locale", + "markdownDescription": "Denies the locale command without any pre-configured scope." + }, + { + "description": "Enables the os_type command without any pre-configured scope.", + "type": "string", + "const": "allow-os-type", + "markdownDescription": "Enables the os_type command without any pre-configured scope." + }, + { + "description": "Denies the os_type command without any pre-configured scope.", + "type": "string", + "const": "deny-os-type", + "markdownDescription": "Denies the os_type command without any pre-configured scope." + }, + { + "description": "Enables the platform command without any pre-configured scope.", + "type": "string", + "const": "allow-platform", + "markdownDescription": "Enables the platform command without any pre-configured scope." + }, + { + "description": "Denies the platform command without any pre-configured scope.", + "type": "string", + "const": "deny-platform", + "markdownDescription": "Denies the platform command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n\n#### This default permission set includes:\n\n- `allow-arch`\n- `allow-exe-extension`\n- `allow-family`\n- `allow-locale`\n- `allow-os-type`\n- `allow-platform`\n- `allow-version`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n\n#### This default permission set includes:\n\n- `allow-arch`\n- `allow-exe-extension`\n- `allow-family`\n- `allow-locale`\n- `allow-os-type`\n- `allow-platform`\n- `allow-version`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/os/rollup.config.js b/plugins/os/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/os/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/os/src/commands.rs b/plugins/os/src/commands.rs new file mode 100644 index 0000000..b10c7f5 --- /dev/null +++ b/plugins/os/src/commands.rs @@ -0,0 +1,13 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[tauri::command] +pub fn locale() -> Option { + crate::locale() +} + +#[tauri::command] +pub fn hostname() -> String { + crate::hostname() +} diff --git a/plugins/os/src/error.rs b/plugins/os/src/error.rs new file mode 100644 index 0000000..7c752ba --- /dev/null +++ b/plugins/os/src/error.rs @@ -0,0 +1,20 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, Serializer}; + +/// All errors that can occur while running the os plugin. +/// +/// This enum currently has no variants: none of the plugin's commands can fail. +#[derive(Debug, thiserror::Error)] +pub enum Error {} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/os/src/init.js b/plugins/os/src/init.js new file mode 100644 index 0000000..97eeab3 --- /dev/null +++ b/plugins/os/src/init.js @@ -0,0 +1,16 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// eslint-disable-next-line +Object.defineProperty(window, '__TAURI_OS_PLUGIN_INTERNALS__', { + value: { + eol: __TEMPLATE_eol__, + os_type: __TEMPLATE_os_type__, + platform: __TEMPLATE_platform__, + family: __TEMPLATE_family__, + version: __TEMPLATE_version__, + arch: __TEMPLATE_arch__, + exe_extension: __TEMPLATE_exe_extension__ + } +}) diff --git a/plugins/os/src/lib.rs b/plugins/os/src/lib.rs new file mode 100644 index 0000000..853dbdf --- /dev/null +++ b/plugins/os/src/lib.rs @@ -0,0 +1,150 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Read information about the operating system. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use std::fmt::Display; + +pub use os_info::Version; +use serialize_to_javascript::{default_template, DefaultTemplate, Template}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +mod commands; +mod error; + +pub use error::Error; + +/// The type of the current operating system, as returned by [`type_`]. +pub enum OsType { + /// Linux and Linux-based systems such as FreeBSD, DragonFly BSD, NetBSD and OpenBSD. + Linux, + /// Windows. + Windows, + /// macOS. + Macos, + /// iOS. + IOS, + /// Android. + Android, +} + +impl Display for OsType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Linux => write!(f, "linux"), + Self::Windows => write!(f, "windows"), + Self::Macos => write!(f, "macos"), + Self::IOS => write!(f, "ios"), + Self::Android => write!(f, "android"), + } + } +} + +/// Returns a string describing the specific operating system in use, see [std::env::consts::OS]. +pub fn platform() -> &'static str { + std::env::consts::OS +} + +/// Returns the current operating system version. +pub fn version() -> Version { + os_info::get().version().clone() +} + +/// Returns the current operating system type. +pub fn type_() -> OsType { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + return OsType::Linux; + #[cfg(target_os = "windows")] + return OsType::Windows; + #[cfg(target_os = "macos")] + return OsType::Macos; + #[cfg(target_os = "ios")] + return OsType::IOS; + #[cfg(target_os = "android")] + return OsType::Android; +} + +/// Returns the current operating system family, see [std::env::consts::FAMILY]. +pub fn family() -> &'static str { + std::env::consts::FAMILY +} + +/// Returns the current operating system architecture, see [std::env::consts::ARCH]. +pub fn arch() -> &'static str { + std::env::consts::ARCH +} + +/// Returns the file extension, if any, used for executable binaries on this platform. Example value is `exe`, see [std::env::consts::EXE_EXTENSION]. +pub fn exe_extension() -> &'static str { + std::env::consts::EXE_EXTENSION +} + +/// Returns the current operating system locale with the `BCP-47` language tag. If the locale couldn't be obtained, `None` is returned instead. +pub fn locale() -> Option { + sys_locale::get_locale() +} + +/// Returns the current operating system hostname. +pub fn hostname() -> String { + gethostname::gethostname().to_string_lossy().to_string() +} + +#[derive(Template)] +#[default_template("./init.js")] +struct InitJavascript<'a> { + eol: &'static str, + os_type: String, + platform: &'a str, + family: &'a str, + version: String, + arch: &'a str, + exe_extension: &'a str, +} + +impl InitJavascript<'_> { + fn new() -> Self { + Self { + #[cfg(windows)] + eol: "\r\n", + #[cfg(not(windows))] + eol: "\n", + os_type: crate::type_().to_string(), + platform: crate::platform(), + family: crate::family(), + version: crate::version().to_string(), + arch: crate::arch(), + exe_extension: crate::exe_extension(), + } + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + let init_js = InitJavascript::new() + .render_default(&Default::default()) + // this will never fail with the above global_os_api values + .unwrap(); + + Builder::new("os") + .js_init_script(init_js.to_string()) + .invoke_handler(tauri::generate_handler![ + commands::locale, + commands::hostname + ]) + .build() +} diff --git a/plugins/os/tsconfig.json b/plugins/os/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/os/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/persisted-scope/CHANGELOG.md b/plugins/persisted-scope/CHANGELOG.md new file mode 100644 index 0000000..3dccf26 --- /dev/null +++ b/plugins/persisted-scope/CHANGELOG.md @@ -0,0 +1,350 @@ +# Changelog + +## [2.3.8] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +### Dependencies + +- Upgraded to `fs@2.5.2` + +## \[2.3.7] + +### Dependencies + +- Upgraded to `fs@2.5.1` + +## \[2.3.6] + +### Dependencies + +- Upgraded to `fs@2.5.0` + +## \[2.3.5] + +### Dependencies + +- Upgraded to `fs@2.4.5` + +## \[2.3.4] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +### Dependencies + +- Upgraded to `fs@2.4.4` + +## \[2.3.3] + +### Dependencies + +- Upgraded to `fs@2.4.3` + +## \[2.3.2] + +### Dependencies + +- Upgraded to `fs@2.4.2` + +## \[2.3.1] + +### Dependencies + +- Upgraded to `fs@2.4.1` + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +### Dependencies + +- Upgraded to `fs@2.4.0` + +## \[2.2.2] + +### Dependencies + +- Upgraded to `fs@2.3.0` + +## \[2.2.1] + +### Dependencies + +- Upgraded to `fs@2.2.1` + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +### Dependencies + +- Upgraded to `fs@2.2.0` + +## \[2.1.1] + +### Dependencies + +- Upgraded to `fs@2.1.1` + +## \[2.1.0] + +- [`fecfd553`](https://github.com/tauri-apps/plugins-workspace/commit/fecfd5533a6452f054fbcd909021f12b0dce834f) ([#2070](https://github.com/tauri-apps/plugins-workspace/pull/2070) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) **Breaking Change:** Replaced the custom `tauri_plugin_fs::Scope` struct with `tauri::fs::Scope`. + +### Dependencies + +- Upgraded to `fs@2.1.0` + +## \[2.0.3] + +### Dependencies + +- Upgraded to `fs@2.0.3` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `fs@2.0.2` + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `fs@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `fs@2.0.0` + +## \[2.0.0-rc.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.6` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.5` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.4` + +## \[2.0.0-rc.3] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.3` + +## \[2.0.0-rc.2] + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.2` + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.1` + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +### Dependencies + +- Upgraded to `fs@2.0.0-rc.0` + +## \[2.0.0-beta.12] + +- [`e847cedc`](https://github.com/tauri-apps/plugins-workspace/commit/e847cedc1f46f3e7a2ad81ea579b620bc5b992d7) ([#1402](https://github.com/tauri-apps/plugins-workspace/pull/1402) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Use no default features on tauri for all plugins so that consumers can use `default-features = false` on tauri, note that this will still enable wry feature on iOS +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.12` + +## \[2.0.0-beta.11] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.11` + +## \[2.0.0-beta.10] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.10` + +## \[2.0.0-beta.9] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.9` + +## \[2.0.0-beta.8] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.8` + +## \[2.0.0-beta.7] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.7` + +## \[2.0.0-beta.6] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.6` + +## \[2.0.0-beta.5] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.5` + +## \[2.0.0-beta.4] + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.4` + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.3` + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.2` + +## \[2.0.0-beta.1] + +- [`14f381a`](https://github.com/tauri-apps/plugins-workspace/commit/14f381acf8fe690acecc676922c6f05939b95734) Update MSRV to 1.75. +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.1` + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Add permissions. + +### Dependencies + +- Upgraded to `fs@2.0.0-beta.0` + +## \[2.0.0-alpha.7] + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.7` + +## \[2.0.0-alpha.6] + +- [`2cf8faa`](https://github.com/tauri-apps/plugins-workspace/commit/2cf8faa3e149af55eb86e5aba8ebfc54210ca703)([#839](https://github.com/tauri-apps/plugins-workspace/pull/839)) Update to tauri@alpha.20. +- [`10b8039`](https://github.com/tauri-apps/plugins-workspace/commit/10b80391fcdef28e26124505053fb3a4c4f85e75)([#825](https://github.com/tauri-apps/plugins-workspace/pull/825)) Use `tauri::scope::fs::Scope` instead of local copy of its implementation. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.6` + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to tauri@alpha.18. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.5` + +## \[2.0.0-alpha.4] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to tauri@alpha.17. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.4` + +## \[2.0.0-alpha.3] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update MSRV to 1.75. +- [`5de23e7`](https://github.com/tauri-apps/plugins-workspace/commit/5de23e79f9880921b62e4b7a8819bc0dbc833216)([#649](https://github.com/tauri-apps/plugins-workspace/pull/649)) Update to tauri@alpha.15. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.3` + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.2` + +## \[2.0.0-alpha.1] + +- [`d5a7c77`](https://github.com/tauri-apps/plugins-workspace/commit/d5a7c77a8d0e7912a6b07b22ed329004edd6e80b)([#545](https://github.com/tauri-apps/plugins-workspace/pull/545)) Fixes docs.rs build by enabling the `tauri/dox` feature flag. +- [`aba07c2`](https://github.com/tauri-apps/plugins-workspace/commit/aba07c27b887c1cc54026024227cb3f74c91e21a)([#468](https://github.com/tauri-apps/plugins-workspace/pull/468)) Split up fs and asset scopes. **This will reset the asset protocol scope once!** +- [`aba07c2`](https://github.com/tauri-apps/plugins-workspace/commit/aba07c27b887c1cc54026024227cb3f74c91e21a)([#468](https://github.com/tauri-apps/plugins-workspace/pull/468)) Fix usage of directory patterns by removing glob asterisks at the end before allowing/forbidding them. This was causing them to be escaped, and so undesirable paths were allowed/forbidden while polluting the `.persisted-scope` file. +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +### Dependencies + +- Upgraded to `fs@2.0.0-alpha.1` + +## \[2.0.0-alpha.0] + +- [`ebb2eb2`](https://github.com/tauri-apps/plugins-workspace/commit/ebb2eb2fe2ebfbb70530d16a983d396aa5829aa1)([#274](https://github.com/tauri-apps/plugins-workspace/pull/274)) Recursively unescape saved patterns before allowing/forbidding them. This effectively prevents `.persisted-scope` files from blowing up, which caused Out-Of-Memory issues, while automatically fixing existing broken files seamlessly. +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! + +## \[0.1.3] + +- Split up fs and asset scopes. **This will reset the asset protocol scope once!** + - [ad30286](https://github.com/tauri-apps/plugins-workspace/commit/ad3028646c96ed213a2f483823ffdc3c17b5fc1e) fix(persisted-scope): separately save asset protocol patterns ([#459](https://github.com/tauri-apps/plugins-workspace/pull/459)) on 2023-07-10 + +## \[0.1.2] + +- Fix usage of directory patterns by removing glob asterisks at the end before allowing/forbidding them. This was causing them to be escaped, and so undesirable paths were allowed/forbidden while polluting the `.persisted_scope` file. + - [9174b80](https://github.com/tauri-apps/plugins-workspace/commit/9174b808dc37154999c119fcc3f31258a9c5a3fb) \[persisted scope] fix: handle recursive directory correctly ([#455](https://github.com/tauri-apps/plugins-workspace/pull/455)) on 2023-06-29 + +## \[0.1.1] + +- The MSRV was raised to 1.64! +- The plugin now recursively unescapes saved patterns before allowing/forbidding them. This effectively prevents `.persisted-scope` files from blowing up, which caused Out-Of-Memory issues, while automatically fixing existing broken files seamlessly. + - [ebb2eb2](https://github.com/tauri-apps/plugins-workspace/commit/ebb2eb2fe2ebfbb70530d16a983d396aa5829aa1) fix(persisted-scope): Prevent out-of-memory issues, fixes [#274](https://github.com/tauri-apps/plugins-workspace/pull/274) ([#328](https://github.com/tauri-apps/plugins-workspace/pull/328)) on 2023-04-26 diff --git a/plugins/persisted-scope/Cargo.toml b/plugins/persisted-scope/Cargo.toml new file mode 100644 index 0000000..507b5ca --- /dev/null +++ b/plugins/persisted-scope/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tauri-plugin-persisted-scope" +version = "2.3.8" +description = "Save filesystem and asset scopes and restore them when the app is reopened." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +exclude = ["/banner.png"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +aho-corasick = "1" +bincode = "1" +tauri-plugin-fs = { path = "../fs", version = "2.5.2" } + +[features] +protocol-asset = ["tauri/protocol-asset"] diff --git a/plugins/persisted-scope/LICENSE.spdx b/plugins/persisted-scope/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/persisted-scope/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/persisted-scope/LICENSE_APACHE-2.0 b/plugins/persisted-scope/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/persisted-scope/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/persisted-scope/LICENSE_MIT b/plugins/persisted-scope/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/persisted-scope/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/persisted-scope/README.md b/plugins/persisted-scope/README.md new file mode 100644 index 0000000..c3ec88f --- /dev/null +++ b/plugins/persisted-scope/README.md @@ -0,0 +1,75 @@ +![plugin-persisted-scope](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/persisted-scope/banner.png) + +Save filesystem and asset scopes and restore them when the app is reopened. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-persisted-scope = "2.0.0" +# alternatively with Git: +tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_persisted_scope::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards the plugin will automatically save and restore filesystem and asset scopes. + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/persisted-scope/SECURITY.md b/plugins/persisted-scope/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/persisted-scope/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/persisted-scope/banner.png b/plugins/persisted-scope/banner.png new file mode 100644 index 0000000..816f0c2 Binary files /dev/null and b/plugins/persisted-scope/banner.png differ diff --git a/plugins/persisted-scope/src/lib.rs b/plugins/persisted-scope/src/lib.rs new file mode 100644 index 0000000..9633165 --- /dev/null +++ b/plugins/persisted-scope/src/lib.rs @@ -0,0 +1,267 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Save filesystem and asset scopes and restore them when the app is reopened. +//! +//! ## Cargo features +//! +//! - **protocol-asset**: Enables `tauri`'s `protocol-asset` feature and adds support for persisting `asset://` protocol scopes. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use aho_corasick::AhoCorasick; +use serde::{Deserialize, Serialize}; + +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; +use tauri_plugin_fs::FsExt; + +use std::{ + fs::{create_dir_all, File}, + io::Write, + path::Path, +}; + +// Using 2 separate files so that we don't have to think about write conflicts and not break backwards compat. +const SCOPE_STATE_FILENAME: &str = ".persisted-scope"; +#[cfg(feature = "protocol-asset")] +const ASSET_SCOPE_STATE_FILENAME: &str = ".persisted-scope-asset"; + +// Most of these patterns are just added to try to fix broken files in the wild. +// After a while we can hopefully reduce it to something like [r"[?]", r"[*]", r"\\?\\\?\"] +const PATTERNS: &[&str] = &[ + r"[[]", + r"[]]", + r"[?]", + r"[*]", + r"\?\?", + r"\\?\\?\", + r"\\?\\\?\", +]; +const REPLACE_WITH: &[&str] = &[r"[", r"]", r"?", r"*", r"\?", r"\\?\", r"\\?\"]; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Tauri(#[from] tauri::Error), + #[error(transparent)] + Bincode(#[from] Box), +} + +#[derive(Debug, Default, Deserialize, Serialize, Eq, PartialEq, Hash)] +enum TargetType { + #[default] + File, + Directory, + RecursiveDirectory, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct Scope { + allowed_paths: Vec, + forbidden_patterns: Vec, +} + +fn fix_pattern(ac: &AhoCorasick, s: &str) -> String { + let s = ac.replace_all(s, REPLACE_WITH); + + if ac.find(&s).is_some() { + return fix_pattern(ac, &s); + } + + s +} + +const RESURSIVE_DIRECTORY_SUFFIX: &str = "**"; +const DIRECTORY_SUFFIX: &str = "*"; + +fn detect_scope_type(scope_state_path: &str) -> TargetType { + if scope_state_path.ends_with(RESURSIVE_DIRECTORY_SUFFIX) { + TargetType::RecursiveDirectory + } else if scope_state_path.ends_with(DIRECTORY_SUFFIX) { + TargetType::Directory + } else { + TargetType::File + } +} + +fn fix_directory(path_str: &str) -> &Path { + let mut path = Path::new(path_str); + + if path.ends_with(DIRECTORY_SUFFIX) || path.ends_with(RESURSIVE_DIRECTORY_SUFFIX) { + path = match path.parent() { + Some(value) => value, + None => return path, + }; + } + + path +} + +fn allow_path(scope: &tauri::fs::Scope, path: &str) { + let target_type = detect_scope_type(path); + + match target_type { + TargetType::File => { + let _ = scope.allow_file(Path::new(path)); + } + TargetType::Directory => { + // We remove the '*' at the end of it, else it will be escaped by the pattern. + let _ = scope.allow_directory(fix_directory(path), false); + } + TargetType::RecursiveDirectory => { + // We remove the '**' at the end of it, else it will be escaped by the pattern. + let _ = scope.allow_directory(fix_directory(path), true); + } + } +} + +fn forbid_path(scope: &tauri::fs::Scope, path: &str) { + let target_type = detect_scope_type(path); + + match target_type { + TargetType::File => { + let _ = scope.forbid_file(Path::new(path)); + } + TargetType::Directory => { + let _ = scope.forbid_directory(fix_directory(path), false); + } + TargetType::RecursiveDirectory => { + let _ = scope.forbid_directory(fix_directory(path), true); + } + } +} + +fn save_scopes(scope: &tauri::fs::Scope, app_dir: &Path, scope_state_path: &Path) { + let scope = Scope { + allowed_paths: scope + .allowed_patterns() + .into_iter() + .map(|p| p.to_string()) + .collect(), + forbidden_patterns: scope + .forbidden_patterns() + .into_iter() + .map(|p| p.to_string()) + .collect(), + }; + + let _ = create_dir_all(app_dir) + .and_then(|_| File::create(scope_state_path)) + .map_err(Error::Io) + .and_then(|mut f| { + f.write_all(&bincode::serialize(&scope).map_err(Error::from)?) + .map_err(Into::into) + }); +} + +/// Initializes the plugin. +/// +/// On setup, this restores the filesystem scope, and the `asset://` protocol scope when the +/// `protocol-asset` feature is enabled, from the state persisted during a previous run, then +/// listens for further scope changes to persist them again. Scopes are stored under the app's +/// data directory; nothing is restored or persisted if that directory cannot be resolved. The +/// `fs` plugin must be registered before this plugin, otherwise the filesystem scope is not +/// restored or persisted (a warning is printed in debug builds). +pub fn init() -> TauriPlugin { + Builder::new("persisted-scope") + .setup(|app, _api| { + let fs_scope = app.try_fs_scope(); + #[cfg(feature = "protocol-asset")] + let asset_protocol_scope = app.asset_protocol_scope(); + let app = app.clone(); + let app_dir = app.path().app_data_dir(); + + if let Ok(app_dir) = app_dir { + let fs_scope_state_path = app_dir.join(SCOPE_STATE_FILENAME); + #[cfg(feature = "protocol-asset")] + let asset_scope_state_path = app_dir.join(ASSET_SCOPE_STATE_FILENAME); + + if let Some(fs_scope) = &fs_scope { + let _ = fs_scope.forbid_file(&fs_scope_state_path); + } else { + #[cfg(debug_assertions)] + eprintln!("Please make sure to register the `fs` plugin before the `persisted-scope` plugin!"); + } + #[cfg(feature = "protocol-asset")] + let _ = asset_protocol_scope.forbid_file(&asset_scope_state_path); + + // We're trying to fix broken .persisted-scope files seamlessly, so we'll be running this on the values read on the saved file. + // We will still save some semi-broken values because the scope events are quite spammy and we don't want to reduce runtime performance any further. + let ac = AhoCorasick::new(PATTERNS).unwrap(/* This should be impossible to fail since we're using a small static input */); + + if let Some(fs_scope) = &fs_scope { + if fs_scope_state_path.exists() { + let scope: Scope = std::fs::read(&fs_scope_state_path) + .map_err(Error::from) + .and_then(|scope| bincode::deserialize(&scope).map_err(Into::into)) + .unwrap_or_default(); + + for allowed in &scope.allowed_paths { + let allowed = fix_pattern(&ac, allowed); + allow_path(fs_scope, &allowed); + } + for forbidden in &scope.forbidden_patterns { + let forbidden = fix_pattern(&ac, forbidden); + forbid_path(fs_scope, &forbidden); + } + + // Manually save the fixed scopes to disk once. + // This is needed to fix broken .peristed-scope files in case the app doesn't update the scope itself. + save_scopes(fs_scope, &app_dir, &fs_scope_state_path); + } + } + + #[cfg(feature = "protocol-asset")] + if asset_scope_state_path.exists() { + let scope: Scope = std::fs::read(&asset_scope_state_path) + .map_err(Error::from) + .and_then(|scope| bincode::deserialize(&scope).map_err(Into::into)) + .unwrap_or_default(); + + for allowed in &scope.allowed_paths { + let allowed = fix_pattern(&ac, allowed); + allow_path(&asset_protocol_scope, &allowed); + } + for forbidden in &scope.forbidden_patterns { + let forbidden = fix_pattern(&ac, forbidden); + forbid_path(&asset_protocol_scope, &forbidden); + } + + // Manually save the fixed scopes to disk once. + save_scopes(&asset_protocol_scope, &app_dir, &asset_scope_state_path); + } + + #[cfg(feature = "protocol-asset")] + let app_dir_ = app_dir.clone(); + + if let Some(fs_scope) = &fs_scope { + fs_scope.listen(move |event| { + if let tauri::fs::Event::PathAllowed(_) = event { + save_scopes(&app.fs_scope(), &app_dir, &fs_scope_state_path); + } + }); + } + + #[cfg(feature = "protocol-asset")] + { + let asset_protocol_scope_ = asset_protocol_scope.clone(); + asset_protocol_scope.listen(move |event| { + if let tauri::scope::fs::Event::PathAllowed(_) = event { + save_scopes(&asset_protocol_scope_, &app_dir_, &asset_scope_state_path); + } + }); + } + } + Ok(()) + }) + .build() +} diff --git a/plugins/positioner/CHANGELOG.md b/plugins/positioner/CHANGELOG.md new file mode 100644 index 0000000..f4710bb --- /dev/null +++ b/plugins/positioner/CHANGELOG.md @@ -0,0 +1,187 @@ +# Changelog + +## [2.3.4] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +## \[2.3.3] + +- [`4be76900`](https://github.com/tauri-apps/plugins-workspace/commit/4be76900854cae3dc91363dea71b54505896e928) ([#3449](https://github.com/tauri-apps/plugins-workspace/pull/3449) by [@skkap](https://github.com/tauri-apps/plugins-workspace/../../skkap)) Replaced a panic in `calculate_position` with an error return when the window has no associated monitor (e.g. during display sleep or monitor reconfiguration). + +## \[2.3.2] + +- [`c0d64bf7`](https://github.com/tauri-apps/plugins-workspace/commit/c0d64bf7d9c0f2c8ed1d2614745e15bbb3cde6a7) ([#3420](https://github.com/tauri-apps/plugins-workspace/pull/3420) by [@UrsDeSwardt](https://github.com/tauri-apps/plugins-workspace/../../UrsDeSwardt)) Removed panics and replaced them with error handling. + +## \[2.3.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.1] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.1.0] + +- [`4db62635`](https://github.com/tauri-apps/plugins-workspace/commit/4db626354c8e29e37bedcf94787a8dd36ce21c55) ([#2076](https://github.com/tauri-apps/plugins-workspace/pull/2076) by [@jakobwesthoff](https://github.com/tauri-apps/plugins-workspace/../../jakobwesthoff)) Add `moveWindowConstrained` function that is similar to `moveWindow` but constrains the window to the screen dimensions in case of tray icon positions. + +## \[2.0.1] + +- [`3c1f3874`](https://github.com/tauri-apps/plugins-workspace/commit/3c1f3874f4c828637b3aa983cba13c77427faf58) ([#1911](https://github.com/tauri-apps/plugins-workspace/pull/1911) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Added missing permission for `handleIconState` and fixed its event processing logic. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.2] + +- [`2f7e32b5`](https://github.com/tauri-apps/plugins-workspace/commit/2f7e32b5e07454d6c0cf3ab03f8af8da74c4a8a7) ([#1822](https://github.com/tauri-apps/plugins-workspace/pull/1822) by [@jbolda](https://github.com/tauri-apps/plugins-workspace/../../jbolda)) `handleIconState` function for use in JavaScript event handlers. This allows one to update the TrayIcon state through JavaScript and fully create and handle the TrayIcon without requiring Rust (and the side-effect of creating a TrayIcon). + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.5] + +- [`d9de5b19`](https://github.com/tauri-apps/plugins-workspace/commit/d9de5b19d1e950c06f0915ae92a862acb266d108)([#1283](https://github.com/tauri-apps/plugins-workspace/pull/1283)) Implement `WindowExt` for `WebviewWindow`. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! + +## \[1.0.5] + +- `TrayLeft`, `TrayRight` and `TrayCenter` will now position the window according to the tray position relative to the monitor dimensions to prevent windows being displayed partially off-screen. + - [3d27909](https://github.com/tauri-apps/plugins-workspace/commit/3d279094d44be78cdc5d1de3938f1414e13db6b0) fix(positioner): Prevent tray relative windows from being moved off-screen ([#291](https://github.com/tauri-apps/plugins-workspace/pull/291)) on 2023-09-27 + +## \[0.2.7] + +- Update Tauri to v1.0.0 + - Bumped due to a bump in tauri-plugin-positioner. + - [0bb73eb](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/0bb73eb20dae87f730c0b5f4cc08e6689e25fdba) Create tauri-v1.md on 2022-06-16 + +## \[0.2.6] + +- Update Tauri to v1.0.0-rc.12 + - Bumped due to a bump in tauri-plugin-positioner. + - [de6d76f](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/de6d76f3a96a68e88a7ac434d65df4dbc82af122) Create update-tauri.md on 2022-05-25 + +## \[0.2.5] + +- Update deps + - Bumped due to a bump in tauri-plugin-positioner. + - [a8d3f73](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/a8d3f73b74829ef5d53d4fb028e59d09e8946399) Create chore-update-deps.md on 2022-05-18 + +## \[0.2.4] + +- Update Tauri dependencies + - [2095b6a](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/2095b6a4a4ab5590add099ddb2b1e8118e3496e4) add dep update changefile on 2022-02-14 + - [53d3a50](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/53d3a501776f16741124aa961f521b9d7798c878) publish new versions ([#42](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/pull/42)) on 2022-02-14 + - [9f32726](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/9f32726ede38bb9b2711f738a2f9fee7f0da2d73) Create update-deps.md on 2022-05-11 + +## \[0.2.3] + +- **Breaking Change**: Uses the new Tauri plugin builder pattern. Use `tauri_plugin_positioner::init()` instead of `tauri_plugin_positioner::Positioner::default()`. + - Bumped due to a bump in tauri-plugin-positioner. + - [14837a8](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/14837a8d9cecdd6014867d4ef00fb98f21b2249d) refactor: use new builder pattern on 2022-02-26 + - [59874d8](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/59874d827471dfb889662fadc74fec1f2243b89e) fix typo on 2022-02-26 + +## \[0.2.2] + +- Update README.md + - Bumped due to a bump in tauri-plugin-positioner. + - [92d6c3d](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/92d6c3dca00a6b3562682804a649c0023831ce2b) Create docs-update-readme.md on 2022-02-17 + +## \[0.2.1] + +- Update `tauri` to `1.0.0-rc.1`, `serde` to `1.0.136` and `serde_json` to `1.0.79`. + - [2095b6a](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/2095b6a4a4ab5590add099ddb2b1e8118e3496e4) add dep update changefile on 2022-02-14 + +## \[0.2.0] + +- Add SystemTray relative positions. + - [765b3ed](https://www.github.com/JonasKruckenberg/tauri-plugin-positioner/commit/765b3ed90056d85ae88b0852b7107ff2b84a6c3a) Create feat-tray-pos.md on 2022-01-19 + +## \[0.1.0] + +- Update package/crate metadata diff --git a/plugins/positioner/Cargo.toml b/plugins/positioner/Cargo.toml new file mode 100644 index 0000000..634eaa0 --- /dev/null +++ b/plugins/positioner/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "tauri-plugin-positioner" +version = "2.3.4" +description = "Position your windows at well-known locations." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-positioner" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +serde_repr = "0.1" + +[features] +tray-icon = ["tauri/tray-icon"] diff --git a/plugins/positioner/LICENSE.spdx b/plugins/positioner/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/positioner/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/positioner/LICENSE_APACHE-2.0 b/plugins/positioner/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/positioner/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/positioner/LICENSE_MIT b/plugins/positioner/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/positioner/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/positioner/README.md b/plugins/positioner/README.md new file mode 100644 index 0000000..8495b61 --- /dev/null +++ b/plugins/positioner/README.md @@ -0,0 +1,148 @@ +![plugin-positioner](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/positioner/banner.png) + +Position your windows at well-known locations. + +This plugin is a port of [electron-positioner](https://github.com/jenslind/electron-positioner) for Tauri. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | x | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-positioner = "2.0.0" +# alternatively with Git: +tauri-plugin-positioner = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-positioner +# or +npm add @tauri-apps/plugin-positioner +# or +yarn add @tauri-apps/plugin-positioner +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +use tauri::tray::TrayIconBuilder; + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_positioner::init()) + // This is required to get tray-relative positions to work + .setup(|app| { + // note that this will create a new TrayIcon + TrayIconBuilder::new() + .on_tray_icon_event(|app, event| { + tauri_plugin_positioner::on_tray_event(app.app_handle(), &event); + }) + .build(app)?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Alternatively, you may handle the tray events through JavaScript. Register the plugin as previously noted. + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_positioner::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +And in JavaScript, the `action` passed to the TrayIcon should include the handler. + +```javascript +import { + moveWindow, + Position, + handleIconState, +} from "@tauri-apps/plugin-positioner"; + +const action = async (event: TrayIconEvent) => { + // add the handle in the action to update the state + await handleIconState(event); + + if (event.type === "Click") { + // note this option requires enabling the `tray-icon` + // feature in the Cargo.toml + await moveWindow(Position.TrayLeft); + } +}; + +const tray = await TrayIcon.new({ id: "main", action }); +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { moveWindow, Position } from '@tauri-apps/plugin-positioner' + +moveWindow(Position.TopRight) +``` + +If you only intend on moving the window from rust code, you can import the Window trait extension instead of registering the plugin: + +```rust +use tauri_plugin_positioner::{WindowExt, Position}; + +let mut win = app.get_window("main").unwrap(); +let _ = win.move_window(Position::TopRight); +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2021 - Jonas Kruckenberg. 2021 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/positioner/SECURITY.md b/plugins/positioner/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/positioner/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/positioner/api-iife.js b/plugins/positioner/api-iife.js new file mode 100644 index 0000000..0163a51 --- /dev/null +++ b/plugins/positioner/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_POSITIONER__=function(t,o){"use strict";var e;return t.Position=void 0,(e=t.Position||(t.Position={}))[e.TopLeft=0]="TopLeft",e[e.TopRight=1]="TopRight",e[e.BottomLeft=2]="BottomLeft",e[e.BottomRight=3]="BottomRight",e[e.TopCenter=4]="TopCenter",e[e.BottomCenter=5]="BottomCenter",e[e.LeftCenter=6]="LeftCenter",e[e.RightCenter=7]="RightCenter",e[e.Center=8]="Center",e[e.TrayLeft=9]="TrayLeft",e[e.TrayBottomLeft=10]="TrayBottomLeft",e[e.TrayRight=11]="TrayRight",e[e.TrayBottomRight=12]="TrayBottomRight",e[e.TrayCenter=13]="TrayCenter",e[e.TrayBottomCenter=14]="TrayBottomCenter",t.handleIconState=async function(t){await o.invoke("plugin:positioner|set_tray_icon_state",{position:t.rect.position,size:t.rect.size})},t.moveWindow=async function(t){await o.invoke("plugin:positioner|move_window",{position:t})},t.moveWindowConstrained=async function(t){await o.invoke("plugin:positioner|move_window_constrained",{position:t})},t}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"positioner",{value:__TAURI_PLUGIN_POSITIONER__})} diff --git a/plugins/positioner/banner.png b/plugins/positioner/banner.png new file mode 100644 index 0000000..1461737 Binary files /dev/null and b/plugins/positioner/banner.png differ diff --git a/plugins/positioner/build.rs b/plugins/positioner/build.rs new file mode 100644 index 0000000..830c61f --- /dev/null +++ b/plugins/positioner/build.rs @@ -0,0 +1,15 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &[ + "move_window", + "move_window_constrained", + "set_tray_icon_state", +]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); +} diff --git a/plugins/positioner/guest-js/index.ts b/plugins/positioner/guest-js/index.ts new file mode 100644 index 0000000..ba35480 --- /dev/null +++ b/plugins/positioner/guest-js/index.ts @@ -0,0 +1,140 @@ +// Copyright 2021 Jonas Kruckenberg +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Move the window to well-known positions, including positions relative to the tray icon. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' +import type { TrayIconEvent } from '@tauri-apps/api/tray' + +/** + * Well known window positions. + */ +export enum Position { + /** Top left corner of the screen. */ + TopLeft = 0, + /** Top right corner of the screen. */ + TopRight, + /** Bottom left corner of the screen. */ + BottomLeft, + /** Bottom right corner of the screen. */ + BottomRight, + /** Top center of the screen. */ + TopCenter, + /** Bottom center of the screen. */ + BottomCenter, + /** Vertically centered on the left edge of the screen. */ + LeftCenter, + /** Vertically centered on the right edge of the screen. */ + RightCenter, + /** Center of the screen. */ + Center, + /** + * Above the tray icon, aligned with its left edge. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayLeft, + /** + * Directly below the tray icon, aligned with its left edge. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayBottomLeft, + /** + * Above the tray icon, aligned with its right edge. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayRight, + /** + * Directly below the tray icon, aligned with its right edge. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayBottomRight, + /** + * Above the tray icon, horizontally centered on it. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayCenter, + /** + * Directly below the tray icon, horizontally centered on it. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ + TrayBottomCenter +} + +/** + * Moves the `Window` to the given {@link Position} using `WindowExt.move_window()` + * All positions are relative to the **current** screen. + * + * @example + * ```typescript + * import { moveWindow, Position } from '@tauri-apps/plugin-positioner' + * + * await moveWindow(Position.TopRight) + * ``` + * + * @param to The {@link Position} to move to. + * @since 2.0.0 + */ +export async function moveWindow(to: Position): Promise { + await invoke('plugin:positioner|move_window', { + position: to + }) +} + +/** + * Moves the `Window` to the given {@link Position} using `WindowExt.move_window_constrained()` + * + * This move operation constrains the window to the screen dimensions in case of + * tray-icon positions. + * + * @example + * ```typescript + * import { moveWindowConstrained, Position } from '@tauri-apps/plugin-positioner' + * + * await moveWindowConstrained(Position.TrayCenter) + * ``` + * + * @param to The (tray) {@link Position} to move to. + * @since 2.1.0 + */ +export async function moveWindowConstrained(to: Position): Promise { + await invoke('plugin:positioner|move_window_constrained', { + position: to + }) +} + +/** + * Reports the tray icon's current position and size so the `Tray*` {@link Position} variants + * can be resolved. + * + * This is an internal helper meant to be called from the tray icon's event handler (the + * `action` callback passed to `TrayIcon.new()`), forwarding every {@link TrayIconEvent} it + * receives so that {@link moveWindow} and {@link moveWindowConstrained} can later position the + * window relative to the tray icon. + * + * @example + * ```typescript + * import { handleIconState } from '@tauri-apps/plugin-positioner' + * import { TrayIcon, type TrayIconEvent } from '@tauri-apps/api/tray' + * + * const action = async (event: TrayIconEvent) => { + * await handleIconState(event) + * } + * + * const tray = await TrayIcon.new({ id: 'main', action }) + * ``` + * + * @param event The tray icon event to read the position and size from. + * @since 2.0.0 + */ +export async function handleIconState(event: TrayIconEvent): Promise { + await invoke('plugin:positioner|set_tray_icon_state', { + position: event.rect.position, + size: event.rect.size + }) +} diff --git a/plugins/positioner/package.json b/plugins/positioner/package.json new file mode 100644 index 0000000..bd27bba --- /dev/null +++ b/plugins/positioner/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-positioner", + "version": "2.3.4", + "description": "Position your windows at well-known locations.", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/positioner/permissions/autogenerated/commands/move_window.toml b/plugins/positioner/permissions/autogenerated/commands/move_window.toml new file mode 100644 index 0000000..f78f9c7 --- /dev/null +++ b/plugins/positioner/permissions/autogenerated/commands/move_window.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-move-window" +description = "Enables the move_window command without any pre-configured scope." +commands.allow = ["move_window"] + +[[permission]] +identifier = "deny-move-window" +description = "Denies the move_window command without any pre-configured scope." +commands.deny = ["move_window"] diff --git a/plugins/positioner/permissions/autogenerated/commands/move_window_constrained.toml b/plugins/positioner/permissions/autogenerated/commands/move_window_constrained.toml new file mode 100644 index 0000000..8034399 --- /dev/null +++ b/plugins/positioner/permissions/autogenerated/commands/move_window_constrained.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-move-window-constrained" +description = "Enables the move_window_constrained command without any pre-configured scope." +commands.allow = ["move_window_constrained"] + +[[permission]] +identifier = "deny-move-window-constrained" +description = "Denies the move_window_constrained command without any pre-configured scope." +commands.deny = ["move_window_constrained"] diff --git a/plugins/positioner/permissions/autogenerated/commands/set_tray_icon_state.toml b/plugins/positioner/permissions/autogenerated/commands/set_tray_icon_state.toml new file mode 100644 index 0000000..6b85f63 --- /dev/null +++ b/plugins/positioner/permissions/autogenerated/commands/set_tray_icon_state.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-tray-icon-state" +description = "Enables the set_tray_icon_state command without any pre-configured scope." +commands.allow = ["set_tray_icon_state"] + +[[permission]] +identifier = "deny-set-tray-icon-state" +description = "Denies the set_tray_icon_state command without any pre-configured scope." +commands.deny = ["set_tray_icon_state"] diff --git a/plugins/positioner/permissions/autogenerated/reference.md b/plugins/positioner/permissions/autogenerated/reference.md new file mode 100644 index 0000000..f6e0913 --- /dev/null +++ b/plugins/positioner/permissions/autogenerated/reference.md @@ -0,0 +1,97 @@ +## Default Permission + +Allows the moveWindow and handleIconState APIs + +#### This default permission set includes the following: + +- `allow-move-window` +- `allow-move-window-constrained` +- `allow-set-tray-icon-state` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`positioner:allow-move-window` + + + +Enables the move_window command without any pre-configured scope. + +
+ +`positioner:deny-move-window` + + + +Denies the move_window command without any pre-configured scope. + +
+ +`positioner:allow-move-window-constrained` + + + +Enables the move_window_constrained command without any pre-configured scope. + +
+ +`positioner:deny-move-window-constrained` + + + +Denies the move_window_constrained command without any pre-configured scope. + +
+ +`positioner:allow-set-tray-icon-state` + + + +Enables the set_tray_icon_state command without any pre-configured scope. + +
+ +`positioner:deny-set-tray-icon-state` + + + +Denies the set_tray_icon_state command without any pre-configured scope. + +
diff --git a/plugins/positioner/permissions/default.toml b/plugins/positioner/permissions/default.toml new file mode 100644 index 0000000..7fc3c3d --- /dev/null +++ b/plugins/positioner/permissions/default.toml @@ -0,0 +1,8 @@ +"$schema" = "schemas/schema.json" +[default] +description = "Allows the moveWindow and handleIconState APIs" +permissions = [ + "allow-move-window", + "allow-move-window-constrained", + "allow-set-tray-icon-state", +] diff --git a/plugins/positioner/permissions/schemas/schema.json b/plugins/positioner/permissions/schemas/schema.json new file mode 100644 index 0000000..b0fc760 --- /dev/null +++ b/plugins/positioner/permissions/schemas/schema.json @@ -0,0 +1,342 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the move_window command without any pre-configured scope.", + "type": "string", + "const": "allow-move-window", + "markdownDescription": "Enables the move_window command without any pre-configured scope." + }, + { + "description": "Denies the move_window command without any pre-configured scope.", + "type": "string", + "const": "deny-move-window", + "markdownDescription": "Denies the move_window command without any pre-configured scope." + }, + { + "description": "Enables the move_window_constrained command without any pre-configured scope.", + "type": "string", + "const": "allow-move-window-constrained", + "markdownDescription": "Enables the move_window_constrained command without any pre-configured scope." + }, + { + "description": "Denies the move_window_constrained command without any pre-configured scope.", + "type": "string", + "const": "deny-move-window-constrained", + "markdownDescription": "Denies the move_window_constrained command without any pre-configured scope." + }, + { + "description": "Enables the set_tray_icon_state command without any pre-configured scope.", + "type": "string", + "const": "allow-set-tray-icon-state", + "markdownDescription": "Enables the set_tray_icon_state command without any pre-configured scope." + }, + { + "description": "Denies the set_tray_icon_state command without any pre-configured scope.", + "type": "string", + "const": "deny-set-tray-icon-state", + "markdownDescription": "Denies the set_tray_icon_state command without any pre-configured scope." + }, + { + "description": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`", + "type": "string", + "const": "default", + "markdownDescription": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/positioner/rollup.config.js b/plugins/positioner/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/positioner/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/positioner/src/ext.rs b/plugins/positioner/src/ext.rs new file mode 100644 index 0000000..a27c18a --- /dev/null +++ b/plugins/positioner/src/ext.rs @@ -0,0 +1,346 @@ +// Copyright 2021 Jonas Kruckenberg +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[cfg(feature = "tray-icon")] +use crate::Tray; +use serde_repr::Deserialize_repr; +#[cfg(feature = "tray-icon")] +use tauri::Manager; +#[cfg(feature = "tray-icon")] +use tauri::Monitor; +use tauri::{PhysicalPosition, PhysicalSize, Result, Runtime, WebviewWindow, Window}; + +/// Well known window positions. +/// +/// The `Tray*` variants require the `tray-icon` feature and only resolve once the tray icon has +/// reported its position (see `on_tray_event`); using one before that happens returns an error. +#[derive(Debug, Deserialize_repr)] +#[repr(u16)] +pub enum Position { + /// Top left corner of the current screen. + TopLeft = 0, + /// Top right corner of the current screen. + TopRight, + /// Bottom left corner of the current screen. + BottomLeft, + /// Bottom right corner of the current screen. + BottomRight, + /// Top center of the current screen. + TopCenter, + /// Bottom center of the current screen. + BottomCenter, + /// Vertically centered on the left edge of the current screen. + LeftCenter, + /// Vertically centered on the right edge of the current screen. + RightCenter, + /// Center of the current screen. + Center, + /// Above the tray icon, aligning the window's left edge with the tray icon's left edge. On + /// Windows and macOS the window moves below the icon instead when there is not enough room + /// above. + #[cfg(feature = "tray-icon")] + TrayLeft, + /// Directly below the tray icon, aligning the window's left edge with the tray icon's left + /// edge. + #[cfg(feature = "tray-icon")] + TrayBottomLeft, + /// Above the tray icon, aligning the window's left edge with the tray icon's right edge. On + /// Windows and macOS the window moves below the icon instead when there is not enough room + /// above. + #[cfg(feature = "tray-icon")] + TrayRight, + /// Directly below the tray icon, aligning the window's left edge with the tray icon's right + /// edge. + #[cfg(feature = "tray-icon")] + TrayBottomRight, + /// Above the tray icon, horizontally centered on it. On Windows and macOS the window moves + /// below the icon instead when there is not enough room above. + #[cfg(feature = "tray-icon")] + TrayCenter, + /// Directly below the tray icon, horizontally centered on it. + #[cfg(feature = "tray-icon")] + TrayBottomCenter, +} + +/// A [`Window`] extension that provides extra methods related to positioning. +pub trait WindowExt { + /// Moves the [`Window`] to the given [`Position`] + /// + /// All (non-tray) positions are relative to the **current** screen. + fn move_window(&self, position: Position) -> Result<()>; + #[cfg(feature = "tray-icon")] + /// Moves the [`Window`] to the given [`Position`] while constraining Tray Positions to the dimensions of the screen. + /// + /// All non-tray positions will not be constrained by this method. + /// + /// This method allows you to position your Tray Windows without having them + /// cut off on the screen borders. + fn move_window_constrained(&self, position: Position) -> Result<()>; +} + +impl WindowExt for WebviewWindow { + fn move_window(&self, pos: Position) -> Result<()> { + self.as_ref().window().move_window(pos) + } + + #[cfg(feature = "tray-icon")] + fn move_window_constrained(&self, position: Position) -> Result<()> { + self.as_ref().window().move_window_constrained(position) + } +} + +impl WindowExt for Window { + #[cfg(feature = "tray-icon")] + fn move_window_constrained(&self, position: Position) -> Result<()> { + // Diverge to basic move_window, if the position is not a tray position + if !matches!( + position, + Position::TrayLeft + | Position::TrayBottomLeft + | Position::TrayRight + | Position::TrayBottomRight + | Position::TrayCenter + | Position::TrayBottomCenter + ) { + return self.move_window(position); + } + + let window_position = calculate_position(self, position)?; + let monitor = get_monitor_for_tray_icon(self)?; + if let Some(monitor) = monitor { + let monitor_size = monitor.size(); + let monitor_position = monitor.position(); + let window_size = self.outer_size()?; + + let right_border_monitor = monitor_position.x as f64 + monitor_size.width as f64; + let left_border_monitor = monitor_position.x as f64; + let right_border_window = window_position.x as f64 + window_size.width as f64; + let left_border_window = window_position.x as f64; + + let constrained_x = if left_border_window < left_border_monitor { + left_border_monitor + } else if right_border_window > right_border_monitor { + right_border_monitor - window_size.width as f64 + } else { + window_position.x as f64 + }; + + let bottom_border_monitor = monitor_position.y as f64 + monitor_size.height as f64; + let top_border_monitor = monitor_position.y as f64; + let bottom_border_window = window_position.y as f64 + window_size.height as f64; + let top_border_window = window_position.y as f64; + + let constrained_y = if top_border_window < top_border_monitor { + top_border_monitor + } else if bottom_border_window > bottom_border_monitor { + bottom_border_monitor - window_size.height as f64 + } else { + window_position.y as f64 + }; + + self.set_position(PhysicalPosition::new(constrained_x, constrained_y))?; + } else { + // Fallback on non constrained positioning + self.set_position(window_position)?; + } + + Ok(()) + } + + fn move_window(&self, pos: Position) -> Result<()> { + let position = calculate_position(self, pos)?; + self.set_position(position) + } +} + +#[cfg(feature = "tray-icon")] +/// Retrieve the monitor, where the tray icon is located on. +fn get_monitor_for_tray_icon(window: &Window) -> Result> { + let tray_position = window + .state::() + .0 + .lock() + .unwrap() + .map(|(pos, _)| pos) + .unwrap_or_default(); + + window.monitor_from_point(tray_position.x, tray_position.y) +} + +/// Calculate the top-left position of the window based on the given +/// [`Position`]. +fn calculate_position( + window: &Window, + pos: Position, +) -> Result> { + use Position::*; + + let screen = window.current_monitor()?.ok_or_else(|| { + tauri::Error::Io(std::io::Error::other("No monitor found for the window")) + })?; + // Only use the screen_position for the Tray independent positioning, + // because a tray event may not be called on the currently active monitor. + let screen_position = screen.position(); + let screen_size = PhysicalSize:: { + width: screen.size().width as i32, + height: screen.size().height as i32, + }; + let window_size = PhysicalSize:: { + width: window.outer_size()?.width as i32, + height: window.outer_size()?.height as i32, + }; + #[cfg(feature = "tray-icon")] + let (tray_position, tray_size) = window + .state::() + .0 + .lock() + .unwrap() + .map(|(pos, size)| { + ( + Some((pos.x as i32, pos.y as i32)), + Some((size.width as i32, size.height as i32)), + ) + }) + .unwrap_or_default(); + + let physical_pos = match pos { + TopLeft => *screen_position, + TopRight => PhysicalPosition { + x: screen_position.x + (screen_size.width - window_size.width), + y: screen_position.y, + }, + BottomLeft => PhysicalPosition { + x: screen_position.x, + y: screen_size.height - (window_size.height - screen_position.y), + }, + BottomRight => PhysicalPosition { + x: screen_position.x + (screen_size.width - window_size.width), + y: screen_size.height - (window_size.height - screen_position.y), + }, + TopCenter => PhysicalPosition { + x: screen_position.x + ((screen_size.width / 2) - (window_size.width / 2)), + y: screen_position.y, + }, + BottomCenter => PhysicalPosition { + x: screen_position.x + ((screen_size.width / 2) - (window_size.width / 2)), + y: screen_size.height - (window_size.height - screen_position.y), + }, + LeftCenter => PhysicalPosition { + x: screen_position.x, + y: screen_position.y + (screen_size.height / 2) - (window_size.height / 2), + }, + RightCenter => PhysicalPosition { + x: screen_position.x + (screen_size.width - window_size.width), + y: screen_position.y + (screen_size.height / 2) - (window_size.height / 2), + }, + Center => PhysicalPosition { + x: screen_position.x + ((screen_size.width / 2) - (window_size.width / 2)), + y: screen_position.y + (screen_size.height / 2) - (window_size.height / 2), + }, + #[cfg(feature = "tray-icon")] + TrayLeft => { + if let (Some((tray_x, tray_y)), Some((_, _tray_height))) = (tray_position, tray_size) { + let y = tray_y - window_size.height; + // Choose y value based on the target OS + #[cfg(target_os = "windows")] + let y = if y < 0 { tray_y + _tray_height } else { y }; + + #[cfg(target_os = "macos")] + let y = if y < 0 { tray_y } else { y }; + + PhysicalPosition { x: tray_x, y } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + #[cfg(feature = "tray-icon")] + TrayBottomLeft => { + if let Some((tray_x, tray_y)) = tray_position { + PhysicalPosition { + x: tray_x, + y: tray_y, + } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + #[cfg(feature = "tray-icon")] + TrayRight => { + if let (Some((tray_x, tray_y)), Some((tray_width, _tray_height))) = + (tray_position, tray_size) + { + let y = tray_y - window_size.height; + // Choose y value based on the target OS + #[cfg(target_os = "windows")] + let y = if y < 0 { tray_y + _tray_height } else { y }; + + #[cfg(target_os = "macos")] + let y = if y < 0 { tray_y } else { y }; + + PhysicalPosition { + x: tray_x + tray_width, + y, + } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + #[cfg(feature = "tray-icon")] + TrayBottomRight => { + if let (Some((tray_x, tray_y)), Some((tray_width, _))) = (tray_position, tray_size) { + PhysicalPosition { + x: tray_x + tray_width, + y: tray_y, + } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + #[cfg(feature = "tray-icon")] + TrayCenter => { + if let (Some((tray_x, tray_y)), Some((tray_width, _tray_height))) = + (tray_position, tray_size) + { + let x = tray_x + tray_width / 2 - window_size.width / 2; + let y = tray_y - window_size.height; + // Choose y value based on the target OS + #[cfg(target_os = "windows")] + let y = if y < 0 { tray_y + _tray_height } else { y }; + + #[cfg(target_os = "macos")] + let y = if y < 0 { tray_y } else { y }; + + PhysicalPosition { x, y } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + #[cfg(feature = "tray-icon")] + TrayBottomCenter => { + if let (Some((tray_x, tray_y)), Some((tray_width, _))) = (tray_position, tray_size) { + PhysicalPosition { + x: tray_x + (tray_width / 2) - (window_size.width / 2), + y: tray_y, + } + } else { + return Err(tauri::Error::Io(std::io::Error::other( + "Tray position not set", + ))); + } + } + }; + + Ok(physical_pos) +} diff --git a/plugins/positioner/src/lib.rs b/plugins/positioner/src/lib.rs new file mode 100644 index 0000000..fd5e826 --- /dev/null +++ b/plugins/positioner/src/lib.rs @@ -0,0 +1,114 @@ +// Copyright 2021 Jonas Kruckenberg +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! A plugin for Tauri that helps position your windows at well-known locations. +//! +//! ## Cargo features +//! +//! - **tray-icon**: Enables tray-icon-relative positions. +//! +//! Note: This requires attaching the Tauri plugin, *even* when using the trait extension only. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] +#![cfg(not(any(target_os = "android", target_os = "ios")))] + +mod ext; + +pub use ext::*; +use tauri::{ + plugin::{self, TauriPlugin}, + Result, Runtime, +}; + +#[cfg(feature = "tray-icon")] +use tauri::{tray::TrayIconEvent, AppHandle, Manager, PhysicalPosition, PhysicalSize}; + +#[cfg(feature = "tray-icon")] +struct Tray(std::sync::Mutex, PhysicalSize)>>); + +/// Records the tray icon's latest position and size so that the `Tray*` [`Position`] variants +/// (e.g. [`Position::TrayLeft`]) can be resolved by [`WindowExt::move_window`] and +/// [`WindowExt::move_window_constrained`]. +/// +/// Call this from your tray icon's event handler. Only [`TrayIconEvent::Click`], +/// [`TrayIconEvent::Enter`], [`TrayIconEvent::Leave`] and [`TrayIconEvent::Move`] carry the +/// icon's position and update the tracked value; other events are ignored. Until this has been +/// called at least once, moving a window to a `Tray*` position fails. +/// +/// Requires the `tray-icon` feature. +#[cfg(feature = "tray-icon")] +pub fn on_tray_event(app: &AppHandle, event: &TrayIconEvent) { + let (position, size) = { + match event { + TrayIconEvent::Click { rect, .. } + | TrayIconEvent::Enter { rect, .. } + | TrayIconEvent::Leave { rect, .. } + | TrayIconEvent::Move { rect, .. } => { + // tray-icon emits PhysicalSize so the scale factor should not matter. + let size = rect.size.to_physical(1.0); + let position = rect.position.to_physical(1.0); + (position, size) + } + + _ => return, + } + }; + + app.state::() + .0 + .lock() + .unwrap() + .replace((position, size)); +} + +#[tauri::command] +async fn move_window(window: tauri::Window, position: Position) -> Result<()> { + window.move_window(position) +} + +#[cfg(feature = "tray-icon")] +#[tauri::command] +async fn move_window_constrained( + window: tauri::Window, + position: Position, +) -> Result<()> { + window.move_window_constrained(position) +} + +#[cfg(feature = "tray-icon")] +#[tauri::command] +fn set_tray_icon_state( + app: AppHandle, + position: PhysicalPosition, + size: PhysicalSize, +) { + app.state::() + .0 + .lock() + .unwrap() + .replace((position, size)); +} + +/// The Tauri plugin that exposes [`WindowExt::move_window`] to the webview. +pub fn init() -> TauriPlugin { + let plugin = plugin::Builder::new("positioner").invoke_handler(tauri::generate_handler![ + move_window, + #[cfg(feature = "tray-icon")] + move_window_constrained, + #[cfg(feature = "tray-icon")] + set_tray_icon_state + ]); + + #[cfg(feature = "tray-icon")] + let plugin = plugin.setup(|app_handle, _api| { + app_handle.manage(Tray(std::sync::Mutex::new(None))); + Ok(()) + }); + + plugin.build() +} diff --git a/plugins/positioner/tsconfig.json b/plugins/positioner/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/positioner/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/process/CHANGELOG.md b/plugins/process/CHANGELOG.md new file mode 100644 index 0000000..b5f88a6 --- /dev/null +++ b/plugins/process/CHANGELOG.md @@ -0,0 +1,106 @@ +# Changelog + +## \[2.3.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.2] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.1] + +- [`d2aef2fd`](https://github.com/tauri-apps/plugins-workspace/commit/d2aef2fddbdfad6526935c55ac10a94171a4f5f5) ([#2581](https://github.com/tauri-apps/plugins-workspace/pull/2581)) Migrate restart to use tauri's new `AppHandle::request_restart` method + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/process/Cargo.toml b/plugins/process/Cargo.toml new file mode 100644 index 0000000..c586cb2 --- /dev/null +++ b/plugins/process/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tauri-plugin-process" +version = "2.3.1" +description = "Access the current process of your Tauri application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-process" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +tauri = { workspace = true } diff --git a/plugins/process/LICENSE.spdx b/plugins/process/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/process/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/process/LICENSE_APACHE-2.0 b/plugins/process/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/process/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/process/LICENSE_MIT b/plugins/process/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/process/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/process/README.md b/plugins/process/README.md new file mode 100644 index 0000000..13a1c4e --- /dev/null +++ b/plugins/process/README.md @@ -0,0 +1,93 @@ +![plugin-process](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/process/banner.png) + +This plugin provides APIs to access the current process. To spawn child processes, see the [`shell`](https://github.com/tauri-apps/tauri-plugin-shell) plugin. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | x | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-process = "2.0.0" +# alternatively with Git: +tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-process +# or +npm add @tauri-apps/plugin-process +# or +yarn add @tauri-apps/plugin-process +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_process::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { exit, relaunch } from '@tauri-apps/plugin-process' +// exit the app with the given status code +await exit(0) +// restart the app +await relaunch() +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/process/SECURITY.md b/plugins/process/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/process/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/process/api-iife.js b/plugins/process/api-iife.js new file mode 100644 index 0000000..d24fd8b --- /dev/null +++ b/plugins/process/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_PROCESS__=function(_,n){"use strict";return _.exit=async function(_=0){await n.invoke("plugin:process|exit",{code:_})},_.relaunch=async function(){await n.invoke("plugin:process|restart")},_}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"process",{value:__TAURI_PLUGIN_PROCESS__})} diff --git a/plugins/process/banner.png b/plugins/process/banner.png new file mode 100644 index 0000000..7634d6e Binary files /dev/null and b/plugins/process/banner.png differ diff --git a/plugins/process/build.rs b/plugins/process/build.rs new file mode 100644 index 0000000..e412b34 --- /dev/null +++ b/plugins/process/build.rs @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["exit", "restart"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); +} diff --git a/plugins/process/guest-js/index.ts b/plugins/process/guest-js/index.ts new file mode 100644 index 0000000..da15831 --- /dev/null +++ b/plugins/process/guest-js/index.ts @@ -0,0 +1,45 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Perform operations on the current process. + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * Exits immediately with the given `exitCode`. + * @example + * ```typescript + * import { exit } from '@tauri-apps/plugin-process'; + * await exit(1); + * ``` + * + * @param code The exit code to use. + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function exit(code = 0): Promise { + await invoke('plugin:process|exit', { code }) +} + +/** + * Exits the current instance of the app then relaunches it. + * @example + * ```typescript + * import { relaunch } from '@tauri-apps/plugin-process'; + * await relaunch(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function relaunch(): Promise { + await invoke('plugin:process|restart') +} + +export { exit, relaunch } diff --git a/plugins/process/package.json b/plugins/process/package.json new file mode 100644 index 0000000..2bde9de --- /dev/null +++ b/plugins/process/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-process", + "version": "2.3.1", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/process/permissions/autogenerated/commands/exit.toml b/plugins/process/permissions/autogenerated/commands/exit.toml new file mode 100644 index 0000000..8abaf29 --- /dev/null +++ b/plugins/process/permissions/autogenerated/commands/exit.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-exit" +description = "Enables the exit command without any pre-configured scope." +commands.allow = ["exit"] + +[[permission]] +identifier = "deny-exit" +description = "Denies the exit command without any pre-configured scope." +commands.deny = ["exit"] diff --git a/plugins/process/permissions/autogenerated/commands/restart.toml b/plugins/process/permissions/autogenerated/commands/restart.toml new file mode 100644 index 0000000..63b228c --- /dev/null +++ b/plugins/process/permissions/autogenerated/commands/restart.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-restart" +description = "Enables the restart command without any pre-configured scope." +commands.allow = ["restart"] + +[[permission]] +identifier = "deny-restart" +description = "Denies the restart command without any pre-configured scope." +commands.deny = ["restart"] diff --git a/plugins/process/permissions/autogenerated/reference.md b/plugins/process/permissions/autogenerated/reference.md new file mode 100644 index 0000000..45d74b7 --- /dev/null +++ b/plugins/process/permissions/autogenerated/reference.md @@ -0,0 +1,76 @@ +## Default Permission + +This permission set configures which +process features are by default exposed. + +#### Granted Permissions + +This enables to quit via `allow-exit` and restart via `allow-restart` +the application. + +#### This default permission set includes the following: + +- `allow-exit` +- `allow-restart` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`process:allow-exit` + + + +Enables the exit command without any pre-configured scope. + +
+ +`process:deny-exit` + + + +Denies the exit command without any pre-configured scope. + +
+ +`process:allow-restart` + + + +Enables the restart command without any pre-configured scope. + +
+ +`process:deny-restart` + + + +Denies the restart command without any pre-configured scope. + +
diff --git a/plugins/process/permissions/default.toml b/plugins/process/permissions/default.toml new file mode 100644 index 0000000..69a9b00 --- /dev/null +++ b/plugins/process/permissions/default.toml @@ -0,0 +1,14 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures which +process features are by default exposed. + +#### Granted Permissions + +This enables to quit via `allow-exit` and restart via `allow-restart` +the application. +""" + +permissions = ["allow-exit", "allow-restart"] diff --git a/plugins/process/permissions/schemas/schema.json b/plugins/process/permissions/schemas/schema.json new file mode 100644 index 0000000..9d68fc6 --- /dev/null +++ b/plugins/process/permissions/schemas/schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the exit command without any pre-configured scope.", + "type": "string", + "const": "allow-exit", + "markdownDescription": "Enables the exit command without any pre-configured scope." + }, + { + "description": "Denies the exit command without any pre-configured scope.", + "type": "string", + "const": "deny-exit", + "markdownDescription": "Denies the exit command without any pre-configured scope." + }, + { + "description": "Enables the restart command without any pre-configured scope.", + "type": "string", + "const": "allow-restart", + "markdownDescription": "Enables the restart command without any pre-configured scope." + }, + { + "description": "Denies the restart command without any pre-configured scope.", + "type": "string", + "const": "deny-restart", + "markdownDescription": "Denies the restart command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/process/rollup.config.js b/plugins/process/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/process/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/process/src/commands.rs b/plugins/process/src/commands.rs new file mode 100644 index 0000000..3777294 --- /dev/null +++ b/plugins/process/src/commands.rs @@ -0,0 +1,15 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{AppHandle, Runtime}; + +#[tauri::command] +pub fn exit(app: AppHandle, code: i32) { + app.exit(code) +} + +#[tauri::command] +pub fn restart(app: AppHandle) { + app.request_restart() +} diff --git a/plugins/process/src/lib.rs b/plugins/process/src/lib.rs new file mode 100644 index 0000000..d7f05f4 --- /dev/null +++ b/plugins/process/src/lib.rs @@ -0,0 +1,24 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! This plugin provides APIs to access the current process. To spawn child processes, see the [`shell`](https://github.com/tauri-apps/tauri-plugin-shell) plugin. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +mod commands; + +/// Initializes the plugin, exposing commands to exit or restart the current process. +pub fn init() -> TauriPlugin { + Builder::new("process") + .invoke_handler(tauri::generate_handler![commands::exit, commands::restart]) + .build() +} diff --git a/plugins/process/tsconfig.json b/plugins/process/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/process/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/shell/CHANGELOG.md b/plugins/shell/CHANGELOG.md new file mode 100644 index 0000000..e8657a1 --- /dev/null +++ b/plugins/shell/CHANGELOG.md @@ -0,0 +1,159 @@ +# Changelog + +## [2.3.6] + +- [`db9c5998`](https://github.com/tauri-apps/plugins-workspace/commit/db9c5998feff9384f9cbbefcbe0d45937c00a1fc) ([#3531](https://github.com/tauri-apps/plugins-workspace/pull/3531) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix missing `consumer-rules.pro` on Gradle v9. + +## \[2.3.5] + +### bug + +- [`d4613ff0`](https://github.com/tauri-apps/plugins-workspace/commit/d4613ff0029ea65f5c6895c3a6f8f5668dd1b55e) ([#3234](https://github.com/tauri-apps/plugins-workspace/pull/3234)) Make sidecars work in tests. Executable resolution is aware of the "deps" directory. + +## \[2.3.4] + +- [`31415eff`](https://github.com/tauri-apps/plugins-workspace/commit/31415effdf5a9ced19934a681cb044a732174088) ([#3183](https://github.com/tauri-apps/plugins-workspace/pull/3183) by [@Tunglies](https://github.com/tauri-apps/plugins-workspace/../../Tunglies)) Docs on example to Encoding usage in `Command::spawn`. No user facing changes. + +## \[2.3.3] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.2] + +- [`6c9b61fb`](https://github.com/tauri-apps/plugins-workspace/commit/6c9b61fb658145d13893626112fc489f7458aa17) ([#3039](https://github.com/tauri-apps/plugins-workspace/pull/3039) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) On Android, updated compileSdk to 36. + +## \[2.3.1] + +- [`d865ed47`](https://github.com/tauri-apps/plugins-workspace/commit/d865ed47685c3923e894f7d10ee4c037507037e6) ([#2950](https://github.com/tauri-apps/plugins-workspace/pull/2950) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fix sidecar with dots in the filename not working on Windows. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.2] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.1] + +### bug + +- [`9cf0390a`](https://github.com/tauri-apps/plugins-workspace/commit/9cf0390a52497e273db1a1b613a0e26827aa327c) Apply the default open validation regex `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+` when the open configuration is not set, preventing unchecked input from being used in this scenario (previously the plugin would skip validation when it should disable all calls). This keeps backwards compatibility while still fixing this vulnerability. + The scope is no longer validated for Rust calls via `ShellExt::shell()` so if you need to block JavaScript from calling the API you can simply set `tauri.conf.json > plugins > shell > open` to `false`. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.1] + +- [`51ddf6a7`](https://github.com/tauri-apps/plugins-workspace/commit/51ddf6a71544acfb261ffc9393dab1342da0a219) ([#1881](https://github.com/tauri-apps/plugins-workspace/pull/1881) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) On Windows, Fix `open` JS API hanging and freezing the app. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.4] + +- [`44273b98`](https://github.com/tauri-apps/plugins-workspace/commit/44273b988957a254eff715d6be7547d2ace882e1) ([#1839](https://github.com/tauri-apps/plugins-workspace/pull/1839) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix the plugin schema requiring to set `sidecar` property when it is in fact optional. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.2] + +- [`b9147758`](https://github.com/tauri-apps/plugins-workspace/commit/b914775898c2bee7ceb20bd17ee595005cd17a64) ([#1679](https://github.com/tauri-apps/plugins-workspace/pull/1679) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Explicitly set a minimum macOS version for the Swift package. + +## \[2.0.0-rc.1] + +### changes + +- [`6b079cfd`](https://github.com/tauri-apps/plugins-workspace/commit/6b079cfdd107c94abc2c7300f6af00bac3ff4040) ([#1649](https://github.com/tauri-apps/plugins-workspace/pull/1649) by [@ahqsoftwares](https://github.com/tauri-apps/plugins-workspace/../../ahqsoftwares)) Remove targetSdk from build.kts files as it is deprecated and will be removed from DSL v9.0 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. +- [`34df132f`](https://github.com/tauri-apps/plugins-workspace/commit/34df132fb14212ba7330adc9ccd64267751950c8) ([#1603](https://github.com/tauri-apps/plugins-workspace/pull/1603)) Change the `open` scope validator regex to match on the entire string. +- [`34df132f`](https://github.com/tauri-apps/plugins-workspace/commit/34df132fb14212ba7330adc9ccd64267751950c8) ([#1603](https://github.com/tauri-apps/plugins-workspace/pull/1603)) Change the `execute` scope argument validator regex to match on the entire string by default. + If this behavior is not desired check the `raw` boolean configuration option that is available along the `validator` string. + +## \[2.0.0-beta.9] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.8] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.7] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.6] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.5] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.4] + +- [`eb1679b9`](https://github.com/tauri-apps/plugins-workspace/commit/eb1679b99780e5d2b867f5649a1ccc2f3f70ab56)([#1299](https://github.com/tauri-apps/plugins-workspace/pull/1299)) Fix `Command.execute` API including extra new lines. +- [`eb1679b9`](https://github.com/tauri-apps/plugins-workspace/commit/eb1679b99780e5d2b867f5649a1ccc2f3f70ab56)([#1299](https://github.com/tauri-apps/plugins-workspace/pull/1299)) Improve the speed of the JS `Command.execute` API + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. +- [`040004a`](https://github.com/tauri-apps/plugins-workspace/commit/040004a6b9fbb89161d1b5764d79428dfe693776)([#1069](https://github.com/tauri-apps/plugins-workspace/pull/1069)) Change shell's schema property name `command` to `cmd`. + +## \[2.0.0-beta.2] + +- [`9586eab`](https://github.com/tauri-apps/plugins-workspace/commit/9586eabd5a96673e4d976757777f470ae358d68a)([#1021](https://github.com/tauri-apps/plugins-workspace/pull/1021)) On Windows, fix `open` can't open file if the file is being used by a program. +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/shell/Cargo.toml b/plugins/shell/Cargo.toml new file mode 100644 index 0000000..105ea5f --- /dev/null +++ b/plugins/shell/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "tauri-plugin-shell" +version = "2.3.6" +description = "Access the system shell. Allows you to spawn child processes and manage files and URLs using their default application." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-shell" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/test", + "/tsconfig.json", +] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "partial", notes = "Only allows to open URLs via `open`" } +ios = { level = "partial", notes = "Only allows to open URLs via `open`" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } +schemars = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +tokio = { version = "1", features = ["time"] } +log = { workspace = true } +thiserror = { workspace = true } +shared_child = "1" +regex = "1" +open = { version = "5", features = ["shellexecute-on-windows"] } +encoding_rs = "0.8" +os_pipe = "1" + +[target.'cfg(target_os = "ios")'.dependencies] +tauri = { workspace = true, features = ["wry"] } diff --git a/plugins/shell/LICENSE.spdx b/plugins/shell/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/shell/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/shell/LICENSE_APACHE-2.0 b/plugins/shell/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/shell/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/shell/LICENSE_MIT b/plugins/shell/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/shell/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/shell/README.md b/plugins/shell/README.md new file mode 100644 index 0000000..132d1cc --- /dev/null +++ b/plugins/shell/README.md @@ -0,0 +1,90 @@ +![plugin-shell](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/shell/banner.png) + +Access the system shell. Allows you to spawn child processes and manage files and URLs using their default application. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | ✓ | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-shell = "2.0.0" +# alternatively with Git: +tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-shell +# or +npm add @tauri-apps/plugin-shell +# or +yarn add @tauri-apps/plugin-shell +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { Command } from '@tauri-apps/plugin-shell' +Command.create('git', ['commit', '-m', 'the commit message']) +``` + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/shell/SECURITY.md b/plugins/shell/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/shell/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/shell/android/.gitignore b/plugins/shell/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/plugins/shell/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/plugins/shell/android/build.gradle.kts b/plugins/shell/android/build.gradle.kts new file mode 100644 index 0000000..3f3ca0e --- /dev/null +++ b/plugins/shell/android/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.shell" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.9.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + implementation(project(":tauri-android")) +} diff --git a/plugins/shell/android/consumer-rules.pro b/plugins/shell/android/consumer-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/plugins/shell/android/consumer-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/shell/android/settings.gradle b/plugins/shell/android/settings.gradle new file mode 100644 index 0000000..14a752e --- /dev/null +++ b/plugins/shell/android/settings.gradle @@ -0,0 +1,2 @@ +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/plugins/shell/android/src/main/AndroidManifest.xml b/plugins/shell/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/plugins/shell/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/plugins/shell/android/src/main/java/ShellPlugin.kt b/plugins/shell/android/src/main/java/ShellPlugin.kt new file mode 100644 index 0000000..2268bc2 --- /dev/null +++ b/plugins/shell/android/src/main/java/ShellPlugin.kt @@ -0,0 +1,30 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.shell + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin +import java.io.File + +@TauriPlugin +class ShellPlugin(private val activity: Activity) : Plugin(activity) { + @Command + fun open(invoke: Invoke) { + try { + val url = invoke.parseArgs(String::class.java) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.applicationContext?.startActivity(intent) + invoke.resolve() + } catch (ex: Exception) { + invoke.reject(ex.message) + } + } +} \ No newline at end of file diff --git a/plugins/shell/api-iife.js b/plugins/shell/api-iife.js new file mode 100644 index 0000000..ee35a32 --- /dev/null +++ b/plugins/shell/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_SHELL__=function(e,t){"use strict";class s{constructor(){this.eventListeners=Object.create(null)}addListener(e,t){return this.on(e,t)}removeListener(e,t){return this.off(e,t)}on(e,t){return e in this.eventListeners?this.eventListeners[e].push(t):this.eventListeners[e]=[t],this}once(e,t){const s=n=>{this.removeListener(e,s),t(n)};return this.addListener(e,s)}off(e,t){return e in this.eventListeners&&(this.eventListeners[e]=this.eventListeners[e].filter(e=>e!==t)),this}removeAllListeners(e){return e?delete this.eventListeners[e]:this.eventListeners=Object.create(null),this}emit(e,t){if(e in this.eventListeners){const s=this.eventListeners[e];for(const e of s)e(t);return!0}return!1}listenerCount(e){return e in this.eventListeners?this.eventListeners[e].length:0}prependListener(e,t){return e in this.eventListeners?this.eventListeners[e].unshift(t):this.eventListeners[e]=[t],this}prependOnceListener(e,t){const s=n=>{this.removeListener(e,s),t(n)};return this.prependListener(e,s)}}class n{constructor(e){this.pid=e}async write(e){await t.invoke("plugin:shell|stdin_write",{pid:this.pid,buffer:e})}async kill(){await t.invoke("plugin:shell|kill",{cmd:"killChild",pid:this.pid})}}class i extends s{constructor(e,t=[],n){super(),this.stdout=new s,this.stderr=new s,this.program=e,this.args="string"==typeof t?[t]:t,this.options=n??{}}static create(e,t=[],s){return new i(e,t,s)}static sidecar(e,t=[],s){const n=new i(e,t,s);return n.options.sidecar=!0,n}async spawn(){const e=this.program,s=this.args,i=this.options;"object"==typeof s&&Object.freeze(s);const r=new t.Channel;return r.onmessage=e=>{switch(e.event){case"Error":this.emit("error",e.payload);break;case"Terminated":this.emit("close",e.payload);break;case"Stdout":this.stdout.emit("data",e.payload);break;case"Stderr":this.stderr.emit("data",e.payload)}},await t.invoke("plugin:shell|spawn",{program:e,args:s,options:i,onEvent:r}).then(e=>new n(e))}async execute(){const e=this.program,s=this.args,n=this.options;return"object"==typeof s&&Object.freeze(s),await t.invoke("plugin:shell|execute",{program:e,args:s,options:n})}}return e.Child=n,e.Command=i,e.EventEmitter=s,e.open=async function(e,s){await t.invoke("plugin:shell|open",{path:e,with:s})},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"shell",{value:__TAURI_PLUGIN_SHELL__})} diff --git a/plugins/shell/banner.png b/plugins/shell/banner.png new file mode 100644 index 0000000..cbf7481 Binary files /dev/null and b/plugins/shell/banner.png differ diff --git a/plugins/shell/build.rs b/plugins/shell/build.rs new file mode 100644 index 0000000..4e19ccd --- /dev/null +++ b/plugins/shell/build.rs @@ -0,0 +1,189 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use schemars::JsonSchema; + +#[path = "src/scope_entry.rs"] +mod scope_entry; + +/// A command argument allowed to be executed by the webview API. +#[derive(Debug, PartialEq, Eq, Clone, Hash, schemars::JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellScopeEntryAllowedArg { + /// A non-configurable argument that is passed to the command in the order it was specified. + Fixed(String), + + /// A variable that is set while calling the command from the webview API. + /// + Var { + /// [regex] validator to require passed values to conform to an expected input. + /// + /// This will require the argument value passed to this variable to match the `validator` regex + /// before it will be executed. + /// + /// The regex string is by default surrounded by `^...$` to match the full string. + /// For example the `https?://\w+` regex would be registered as `^https?://\w+$`. + /// + /// [regex]: + validator: String, + + /// Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime. + /// + /// This means the regex will not match on the entire string by default, which might + /// be exploited if your regex allow unexpected input to be considered valid. + /// When using this option, make sure your regex is correct. + #[serde(default)] + raw: bool, + }, +} + +/// A set of command arguments allowed to be executed by the webview API. +/// +/// A value of `true` will allow any arguments to be passed to the command. `false` will disable all +/// arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to +/// be passed to the attached command configuration. +#[derive(Debug, PartialEq, Eq, Clone, Hash, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellScopeEntryAllowedArgs { + /// Use a simple boolean to allow all or disable all arguments to this command configuration. + Flag(bool), + + /// A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration. + List(Vec), +} + +impl Default for ShellScopeEntryAllowedArgs { + fn default() -> Self { + Self::Flag(false) + } +} + +/// Shell scope entry. +#[derive(JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +#[allow(unused)] +pub(crate) enum ShellScopeEntry { + Command { + /// The name for this allowed shell command configuration. + /// + /// This name will be used inside of the webview API to call this command along with + /// any specified arguments. + name: String, + /// The command name. + /// It can start with a variable that resolves to a system base directory. + /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, + /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, + /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, + /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`. + // use default just so the schema doesn't flag it as required + #[serde(rename = "cmd")] + command: PathBuf, + /// The allowed arguments for the command execution. + #[serde(default)] + args: ShellScopeEntryAllowedArgs, + }, + Sidecar { + /// The name for this allowed shell command configuration. + /// + /// This name will be used inside of the webview API to call this command along with + /// any specified arguments. + name: String, + /// The allowed arguments for the command execution. + #[serde(default)] + args: ShellScopeEntryAllowedArgs, + /// If this command is a sidecar command. + sidecar: bool, + }, +} + +// Ensure `ShellScopeEntry` and `scope_entry::EntryRaw` +// and `ShellScopeEntryAllowedArg` and `ShellAllowedArg` +// and `ShellScopeEntryAllowedArgs` and `ShellAllowedArgs` +// are kept in sync +#[allow(clippy::unnecessary_operation)] +fn _f() { + match (ShellScopeEntry::Sidecar { + name: String::new(), + args: ShellScopeEntryAllowedArgs::Flag(false), + sidecar: true, + }) { + ShellScopeEntry::Command { + name, + command, + args, + } => scope_entry::EntryRaw { + name, + command: Some(command), + args: match args { + ShellScopeEntryAllowedArgs::Flag(flag) => scope_entry::ShellAllowedArgs::Flag(flag), + ShellScopeEntryAllowedArgs::List(vec) => scope_entry::ShellAllowedArgs::List( + vec.into_iter() + .map(|s| match s { + ShellScopeEntryAllowedArg::Fixed(fixed) => { + scope_entry::ShellAllowedArg::Fixed(fixed) + } + ShellScopeEntryAllowedArg::Var { validator, raw } => { + scope_entry::ShellAllowedArg::Var { validator, raw } + } + }) + .collect(), + ), + }, + sidecar: false, + }, + ShellScopeEntry::Sidecar { + name, + args, + sidecar, + } => scope_entry::EntryRaw { + name, + command: None, + args: match args { + ShellScopeEntryAllowedArgs::Flag(flag) => scope_entry::ShellAllowedArgs::Flag(flag), + ShellScopeEntryAllowedArgs::List(vec) => scope_entry::ShellAllowedArgs::List( + vec.into_iter() + .map(|s| match s { + ShellScopeEntryAllowedArg::Fixed(fixed) => { + scope_entry::ShellAllowedArg::Fixed(fixed) + } + ShellScopeEntryAllowedArg::Var { validator, raw } => { + scope_entry::ShellAllowedArg::Var { validator, raw } + } + }) + .collect(), + ), + }, + sidecar, + }, + }; +} + +const COMMANDS: &[&str] = &["execute", "spawn", "stdin_write", "kill", "open"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .global_scope_schema(schemars::schema_for!(ShellScopeEntry)) + .android_path("android") + .ios_path("ios") + .build(); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let mobile = target_os == "ios" || target_os == "android"; + alias("desktop", !mobile); + alias("mobile", mobile); +} + +// creates a cfg alias if `has_feature` is true. +// `alias` must be a snake case string. +fn alias(alias: &str, has_feature: bool) { + println!("cargo:rustc-check-cfg=cfg({alias})"); + if has_feature { + println!("cargo:rustc-cfg={alias}"); + } +} diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts new file mode 100644 index 0000000..afac302 --- /dev/null +++ b/plugins/shell/guest-js/index.ts @@ -0,0 +1,928 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Access the system shell. + * Allows you to spawn child processes and manage files and URLs using their default application. + * + * ## Security + * + * This API has a scope configuration that forces you to restrict the programs and arguments that can be used. + * + * ### Restricting access to the {@link open | `open`} API + * + * On the configuration object, `open: true` means that the {@link open} API can be used with any URL, + * as the argument is validated with the `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+` regex. + * You can change that regex by changing the boolean value to a string, e.g. `open: ^https://github.com/`. + * + * ### Restricting access to the {@link Command | `Command`} APIs + * + * The plugin permissions object has a `scope` field that defines an array of CLIs that can be used. + * Each CLI is a configuration object `{ name: string, cmd: string, sidecar?: bool, args?: boolean | Arg[] }`. + * + * - `name`: the unique identifier of the command, passed to the {@link Command.create | Command.create function}. + * If it's a sidecar, this must be the value defined on `tauri.conf.json > bundle > externalBin`. + * - `cmd`: the program that is executed on this configuration. If it's a sidecar, this value is ignored. + * - `sidecar`: whether the object configures a sidecar or a system program. + * - `args`: the arguments that can be passed to the program. By default no arguments are allowed. + * - `true` means that any argument list is allowed. + * - `false` means that no arguments are allowed. + * - otherwise an array can be configured. Each item is either a string representing the fixed argument value + * or a `{ validator: string }` that defines a regex validating the argument value. + * + * #### Example scope configuration + * + * CLI: `git commit -m "the commit message"` + * + * Capability: + * ```json + * { + * "permissions": [ + * { + * "identifier": "shell:allow-execute", + * "allow": [ + * { + * "name": "run-git-commit", + * "cmd": "git", + * "args": ["commit", "-m", { "validator": "\\S+" }] + * } + * ] + * } + * ] + * } + * ``` + * Usage: + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell' + * Command.create('run-git-commit', ['commit', '-m', 'the commit message']) + * ``` + * + * Trying to execute any API with a program not configured on the scope results in a promise rejection due to denied access. + * + * @module + */ + +import { invoke, Channel } from '@tauri-apps/api/core' + +/** + * Options that configure how a child process is spawned. + * + * @since 2.0.0 + */ +interface SpawnOptions { + /** Current working directory. */ + cwd?: string + /** Environment variables. set to `null` to clear the process env. */ + env?: Record + /** + * Character encoding for stdout/stderr + * + * @since 2.0.0 + * */ + encoding?: string +} + +/** @ignore */ +interface InternalSpawnOptions extends SpawnOptions { + sidecar?: boolean +} + +/** + * The output collected from a child process that ran to completion. + * + * @since 2.0.0 + */ +interface ChildProcess { + /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ + code: number | null + /** If the process was terminated by a signal, represents that signal. */ + signal: number | null + /** The data that the process wrote to `stdout`. */ + stdout: O + /** The data that the process wrote to `stderr`. */ + stderr: O +} + +/** + * A minimal event emitter modeled after Node.js' `EventEmitter`, used by + * {@link Command} and by its `stdout` and `stderr` streams. + * + * @since 2.0.0 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +class EventEmitter> { + /** @ignore */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + private eventListeners: Record void>> = + Object.create(null) + + /** + * Alias for `emitter.on(eventName, listener)`. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.addListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + addListener( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + return this.on(eventName, listener) + } + + /** + * Alias for `emitter.off(eventName, listener)`. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const listener = (error: string) => console.error(error); + * command.addListener('error', listener); + * command.removeListener('error', listener); + * ``` + * + * @param eventName The name of the event to stop listening to. + * @param listener The exact callback that was registered before. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + removeListener( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + return this.off(eventName, listener) + } + + /** + * Adds the `listener` function to the end of the listeners array for the + * event named `eventName`. No checks are made to see if the `listener` has + * already been added. Multiple calls passing the same combination of `eventName`and `listener` will result in the `listener` being added, and called, multiple + * times. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('close', (data) => { + * console.log(`command finished with code ${data.code}`); + * }); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + on( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + if (eventName in this.eventListeners) { + // eslint-disable-next-line security/detect-object-injection + this.eventListeners[eventName].push(listener) + } else { + // eslint-disable-next-line security/detect-object-injection + this.eventListeners[eventName] = [listener] + } + return this + } + + /** + * Adds a **one-time**`listener` function for the event named `eventName`. The + * next time `eventName` is triggered, this listener is removed and then invoked. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.once('close', (data) => { + * console.log(`command finished with code ${data.code}`); + * }); + * ``` + * + * @param eventName The name of the event to listen to once. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + once( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + const wrapper = (arg: E[typeof eventName]): void => { + this.removeListener(eventName, wrapper) + listener(arg) + } + return this.addListener(eventName, wrapper) + } + + /** + * Removes the all specified listener from the listener array for the event eventName + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const listener = (error: string) => console.error(error); + * command.on('error', listener); + * command.off('error', listener); + * ``` + * + * @param eventName The name of the event to stop listening to. + * @param listener The exact callback that was registered before. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + off( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + if (eventName in this.eventListeners) { + // eslint-disable-next-line security/detect-object-injection + this.eventListeners[eventName] = this.eventListeners[eventName].filter( + (l) => l !== listener + ) + } + return this + } + + /** + * Removes all listeners, or those of the specified eventName. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('error', (error) => console.error(error)); + * command.removeAllListeners('error'); + * ``` + * + * @param event The name of the event to remove the listeners of. + * When omitted, the listeners of every event are removed. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + removeAllListeners(event?: N): this { + if (event) { + // eslint-disable-next-line security/detect-object-injection + delete this.eventListeners[event] + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.eventListeners = Object.create(null) + } + return this + } + + /** + * Synchronously calls each of the listeners registered for the event named + * `eventName`, in the order they were registered, passing the supplied arguments + * to each. + * + * @ignore + * + * @example + * ```typescript + * import { EventEmitter } from '@tauri-apps/plugin-shell'; + * const emitter = new EventEmitter<{ data: string }>(); + * emitter.on('data', (line) => console.log(line)); + * emitter.emit('data', 'hello'); + * ``` + * + * @param eventName The name of the event to emit. + * @param arg The payload passed to every registered listener. + * + * @returns `true` if the event had listeners, `false` otherwise. + * + * @since 2.0.0 + */ + emit(eventName: N, arg: E[typeof eventName]): boolean { + if (eventName in this.eventListeners) { + // eslint-disable-next-line security/detect-object-injection + const listeners = this.eventListeners[eventName] + for (const listener of listeners) listener(arg) + return true + } + return false + } + + /** + * Returns the number of listeners listening to the event named `eventName`. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('close', () => {}); + * console.log(command.listenerCount('close')); // 1 + * ``` + * + * @param eventName The name of the event to count the listeners of. + * + * @returns The number of listeners registered for the given event. + * + * @since 2.0.0 + */ + listenerCount(eventName: N): number { + if (eventName in this.eventListeners) + // eslint-disable-next-line security/detect-object-injection + return this.eventListeners[eventName].length + return 0 + } + + /** + * Adds the `listener` function to the _beginning_ of the listeners array for the + * event named `eventName`. No checks are made to see if the `listener` has + * already been added. Multiple calls passing the same combination of `eventName`and `listener` will result in the `listener` being added, and called, multiple + * times. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.prependListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + prependListener( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + if (eventName in this.eventListeners) { + // eslint-disable-next-line security/detect-object-injection + this.eventListeners[eventName].unshift(listener) + } else { + // eslint-disable-next-line security/detect-object-injection + this.eventListeners[eventName] = [listener] + } + return this + } + + /** + * Adds a **one-time**`listener` function for the event named `eventName` to the_beginning_ of the listeners array. The next time `eventName` is triggered, this + * listener is removed, and then invoked. + * + * Returns a reference to the `EventEmitter`, so that calls can be chained. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.prependOnceListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to once. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * + * @since 2.0.0 + */ + prependOnceListener( + eventName: N, + listener: (arg: E[typeof eventName]) => void + ): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapper = (arg: any): void => { + this.removeListener(eventName, wrapper) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listener(arg) + } + return this.prependListener(eventName, wrapper) + } +} + +/** + * A handle to a child process spawned with {@link Command.spawn}, + * which can be used to write to its `stdin` or to kill it. + * + * @since 2.0.0 + */ +class Child { + /** The child process `pid`. */ + pid: number + + /** + * Creates a handle to the child process with the given process id. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * // a `Child` is usually obtained by spawning a command: + * const child = await Command.create('node').spawn(); + * console.log(child.pid); + * ``` + * + * @param pid The process id of the child process. + * + * @since 2.0.0 + */ + constructor(pid: number) { + this.pid = pid + } + + /** + * Writes `data` to the `stdin`. + * + * @param data The message to write, either a string or a byte array. + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const child = await command.spawn(); + * await child.write('message'); + * await child.write([0, 1, 2, 3, 4, 5]); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ + async write(data: IOPayload | number[]): Promise { + await invoke('plugin:shell|stdin_write', { + pid: this.pid, + buffer: data + }) + } + + /** + * Kills the child process. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const child = await command.spawn(); + * await child.kill(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ + async kill(): Promise { + await invoke('plugin:shell|kill', { + cmd: 'killChild', + pid: this.pid + }) + } +} + +/** + * The events emitted by a {@link Command} instance. + * + * @since 2.0.0 + */ +interface CommandEvents { + /** Emitted when the child process terminated, carrying its exit code and signal. */ + close: TerminatedPayload + /** Emitted when the child process could not be spawned or failed unexpectedly, carrying the error message. */ + error: string +} + +/** + * The events emitted by the `stdout` and `stderr` streams of a {@link Command}. + * + * @since 2.0.0 + */ +interface OutputEvents { + /** Emitted for each line the process wrote to the stream, or for each raw chunk when the `raw` encoding is used. */ + data: O +} + +/** + * The entry point for spawning child processes. + * It emits the `close` and `error` events. + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('close', data => { + * console.log(`command finished with code ${data.code} and signal ${data.signal}`) + * }); + * command.on('error', error => console.error(`command error: "${error}"`)); + * command.stdout.on('data', line => console.log(`command stdout: "${line}"`)); + * command.stderr.on('data', line => console.log(`command stderr: "${line}"`)); + * + * const child = await command.spawn(); + * console.log('pid:', child.pid); + * ``` + * + * @since 2.0.0 + * + */ +class Command extends EventEmitter { + /** @ignore Program to execute. */ + private readonly program: string + /** @ignore Program arguments */ + private readonly args: string[] + /** @ignore Spawn options. */ + private readonly options: InternalSpawnOptions + /** Event emitter for the `stdout`. Emits the `data` event. */ + readonly stdout = new EventEmitter>() + /** Event emitter for the `stderr`. Emits the `data` event. */ + readonly stderr = new EventEmitter>() + + /** + * @ignore + * Creates a new `Command` instance. + * + * @param program The program name to execute. + * It must be configured in your project's capabilities. + * @param args Program arguments. + * @param options Spawn options. + */ + private constructor( + program: string, + args: string | string[] = [], + options?: SpawnOptions + ) { + super() + this.program = program + this.args = typeof args === 'string' ? [args] : args + this.options = options ?? {} + } + + /** + * Creates a command to execute the given program, decoding its output as text. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('my-app', ['run', 'tauri']); + * const output = await command.execute(); + * ``` + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create(program: string, args?: string | string[]): Command + /** + * Creates a command to execute the given program, keeping its output as raw bytes. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('my-app', ['run', 'tauri'], { encoding: 'raw' }); + * const output = await command.execute(); + * console.log(output.stdout); // a Uint8Array + * ``` + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options using the `raw` encoding, which makes the process + * output be delivered as `Uint8Array` instead of `string`. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create( + program: string, + args?: string | string[], + options?: SpawnOptions & { encoding: 'raw' } + ): Command + /** + * Creates a command to execute the given program with the given spawn options. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('my-app', ['run', 'tauri'], { cwd: '/path/to/project' }); + * const output = await command.execute(); + * ``` + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create( + program: string, + args?: string | string[], + options?: SpawnOptions + ): Command + + /** + * Creates a command to execute the given program. + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create( + program: string, + args: string | string[] = [], + options?: SpawnOptions + ): Command { + return new Command(program, args, options) + } + + /** + * Creates a command to execute the given sidecar program, decoding its output as text. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar'); + * const output = await command.execute(); + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static sidecar(program: string, args?: string | string[]): Command + /** + * Creates a command to execute the given sidecar program, keeping its output as raw bytes. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar', [], { encoding: 'raw' }); + * const output = await command.execute(); + * console.log(output.stdout); // a Uint8Array + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options using the `raw` encoding, which makes the process + * output be delivered as `Uint8Array` instead of `string`. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static sidecar( + program: string, + args?: string | string[], + options?: SpawnOptions & { encoding: 'raw' } + ): Command + /** + * Creates a command to execute the given sidecar program with the given spawn options. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar', [], { cwd: '/path/to/project' }); + * const output = await command.execute(); + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static sidecar( + program: string, + args?: string | string[], + options?: SpawnOptions + ): Command + + /** + * Creates a command to execute the given sidecar program. + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static sidecar( + program: string, + args: string | string[] = [], + options?: SpawnOptions + ): Command { + const instance = new Command(program, args, options) + instance.options.sidecar = true + return instance + } + + /** + * Executes the command as a child process, returning a handle to it. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.stdout.on('data', (line) => console.log(line)); + * const child = await command.spawn(); + * console.log('pid:', child.pid); + * ``` + * + * @returns A promise resolving to the child process handle. + * + * @since 2.0.0 + */ + async spawn(): Promise { + const program = this.program + const args = this.args + const options = this.options + + if (typeof args === 'object') { + Object.freeze(args) + } + + const onEvent = new Channel>() + onEvent.onmessage = (event) => { + switch (event.event) { + case 'Error': + this.emit('error', event.payload) + break + case 'Terminated': + this.emit('close', event.payload) + break + case 'Stdout': + this.stdout.emit('data', event.payload) + break + case 'Stderr': + this.stderr.emit('data', event.payload) + break + } + } + + return await invoke('plugin:shell|spawn', { + program, + args, + options, + onEvent + }).then((pid) => new Child(pid)) + } + + /** + * Executes the command as a child process, waiting for it to finish and collecting all of its output. + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const output = await Command.create('echo', 'message').execute(); + * assert(output.code === 0); + * assert(output.signal === null); + * assert(output.stdout === 'message'); + * assert(output.stderr === ''); + * ``` + * + * @returns A promise resolving to the child process output. + * + * @since 2.0.0 + */ + async execute(): Promise> { + const program = this.program + const args = this.args + const options = this.options + + if (typeof args === 'object') { + Object.freeze(args) + } + + return await invoke>('plugin:shell|execute', { + program, + args, + options + }) + } +} + +/** + * Describes the event message received from the command. + */ +interface Event { + event: T + payload: V +} + +/** + * Payload for the `Terminated` command event. + */ +interface TerminatedPayload { + /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ + code: number | null + /** If the process was terminated by a signal, represents that signal. */ + signal: number | null +} + +/** + * The type of the data a child process writes to `stdout` and `stderr`: + * a `string`, or a `Uint8Array` when the `raw` encoding is configured. + */ +type IOPayload = string | Uint8Array + +/** Events emitted by the child process. */ +type CommandEvent = + | Event<'Stdout', O> + | Event<'Stderr', O> + | Event<'Terminated', TerminatedPayload> + | Event<'Error', string> + +/** + * Opens a path or URL with the system's default app, + * or the one specified with `openWith`. + * + * The `openWith` value must be one of `firefox`, `google chrome`, `chromium` `safari`, + * `open`, `start`, `xdg-open`, `gio`, `gnome-open`, `kde-open` or `wslview`. + * + * @example + * ```typescript + * import { open } from '@tauri-apps/plugin-shell'; + * // opens the given URL on the default browser: + * await open('https://github.com/tauri-apps/tauri'); + * // opens the given URL using `firefox`: + * await open('https://github.com/tauri-apps/tauri', 'firefox'); + * // opens a file using the default program: + * await open('/path/to/file'); + * ``` + * + * @param path The path or URL to open. + * This value is matched against the string regex defined on `tauri.conf.json > plugins > shell > open`, + * which defaults to `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`. + * @param openWith The app to open the file or URL with. + * Defaults to the system default application for the specified path type. + * + * @since 2.0.0 + */ +async function open(path: string, openWith?: string): Promise { + await invoke('plugin:shell|open', { + path, + with: openWith + }) +} + +export { Command, Child, EventEmitter, open } +export type { + IOPayload, + CommandEvents, + TerminatedPayload, + OutputEvents, + ChildProcess, + SpawnOptions +} diff --git a/plugins/shell/guest-js/init.ts b/plugins/shell/guest-js/init.ts new file mode 100644 index 0000000..d0ed650 --- /dev/null +++ b/plugins/shell/guest-js/init.ts @@ -0,0 +1,51 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Webview script injected by the shell plugin on every page load. + * + * It installs a `click` listener on `document.body` that intercepts clicks on + * `
` elements whose `href` starts with `http://`, `https://`, + * `mailto:` or `tel:`, cancels the navigation and opens the link with the + * system's default application through the `plugin:shell|open` command instead. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +// open links with the API +function openLinks(): void { + document.querySelector('body')?.addEventListener('click', function (e) { + let target: HTMLElement | null = e.target as HTMLElement + while (target) { + if (target.matches('a')) { + const t = target + if ( + t.href !== '' + && ['http://', 'https://', 'mailto:', 'tel:'].some((v) => + t.href.startsWith(v) + ) + && t.target === '_blank' + ) { + void invoke('plugin:shell|open', { + path: t.href + }) + e.preventDefault() + } + break + } + target = target.parentElement + } + }) +} + +if ( + document.readyState === 'complete' + || document.readyState === 'interactive' +) { + openLinks() +} else { + window.addEventListener('DOMContentLoaded', openLinks, true) +} diff --git a/plugins/shell/ios/Package.resolved b/plugins/shell/ios/Package.resolved new file mode 100644 index 0000000..5f998e0 --- /dev/null +++ b/plugins/shell/ios/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "SwiftRs", + "repositoryURL": "https://github.com/Brendonovich/swift-rs", + "state": { + "branch": null, + "revision": "b5ed223fcdab165bc21219c1925dc1e77e2bef5e", + "version": "1.0.6" + } + } + ] + }, + "version": 1 +} diff --git a/plugins/shell/ios/Package.swift b/plugins/shell/ios/Package.swift new file mode 100644 index 0000000..c7b2a7a --- /dev/null +++ b/plugins/shell/ios/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.3 +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import PackageDescription + +let package = Package( + name: "tauri-plugin-shell", + platforms: [ + .macOS(.v10_13), + .iOS(.v13), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-shell", + type: .static, + targets: ["tauri-plugin-shell"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-shell", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources") + ] +) diff --git a/plugins/shell/ios/Sources/ShellPlugin.swift b/plugins/shell/ios/Sources/ShellPlugin.swift new file mode 100644 index 0000000..0fcb7da --- /dev/null +++ b/plugins/shell/ios/Sources/ShellPlugin.swift @@ -0,0 +1,34 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation + +import SwiftRs +import Tauri +import UIKit +import WebKit + +class ShellPlugin: Plugin { + + @objc public func open(_ invoke: Invoke) throws { + do { + let urlString = try invoke.parseArgs(String.self) + if let url = URL(string: urlString) { + if #available(iOS 10, *) { + UIApplication.shared.open(url, options: [:]) + } else { + UIApplication.shared.openURL(url) + } + } + invoke.resolve() + } catch { + invoke.reject(error.localizedDescription) + } + } +} + +@_cdecl("init_plugin_shell") +func initPlugin() -> Plugin { + return ShellPlugin() +} diff --git a/plugins/shell/package.json b/plugins/shell/package.json new file mode 100644 index 0000000..cf5ef5c --- /dev/null +++ b/plugins/shell/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-shell", + "version": "2.3.6", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/shell/permissions/autogenerated/commands/execute.toml b/plugins/shell/permissions/autogenerated/commands/execute.toml new file mode 100644 index 0000000..d98be89 --- /dev/null +++ b/plugins/shell/permissions/autogenerated/commands/execute.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-execute" +description = "Enables the execute command without any pre-configured scope." +commands.allow = ["execute"] + +[[permission]] +identifier = "deny-execute" +description = "Denies the execute command without any pre-configured scope." +commands.deny = ["execute"] diff --git a/plugins/shell/permissions/autogenerated/commands/kill.toml b/plugins/shell/permissions/autogenerated/commands/kill.toml new file mode 100644 index 0000000..11d2f7f --- /dev/null +++ b/plugins/shell/permissions/autogenerated/commands/kill.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-kill" +description = "Enables the kill command without any pre-configured scope." +commands.allow = ["kill"] + +[[permission]] +identifier = "deny-kill" +description = "Denies the kill command without any pre-configured scope." +commands.deny = ["kill"] diff --git a/plugins/shell/permissions/autogenerated/commands/open.toml b/plugins/shell/permissions/autogenerated/commands/open.toml new file mode 100644 index 0000000..4ea6dff --- /dev/null +++ b/plugins/shell/permissions/autogenerated/commands/open.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-open" +description = "Enables the open command without any pre-configured scope." +commands.allow = ["open"] + +[[permission]] +identifier = "deny-open" +description = "Denies the open command without any pre-configured scope." +commands.deny = ["open"] diff --git a/plugins/shell/permissions/autogenerated/commands/spawn.toml b/plugins/shell/permissions/autogenerated/commands/spawn.toml new file mode 100644 index 0000000..a3802d2 --- /dev/null +++ b/plugins/shell/permissions/autogenerated/commands/spawn.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-spawn" +description = "Enables the spawn command without any pre-configured scope." +commands.allow = ["spawn"] + +[[permission]] +identifier = "deny-spawn" +description = "Denies the spawn command without any pre-configured scope." +commands.deny = ["spawn"] diff --git a/plugins/shell/permissions/autogenerated/commands/stdin_write.toml b/plugins/shell/permissions/autogenerated/commands/stdin_write.toml new file mode 100644 index 0000000..46ca5a7 --- /dev/null +++ b/plugins/shell/permissions/autogenerated/commands/stdin_write.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stdin-write" +description = "Enables the stdin_write command without any pre-configured scope." +commands.allow = ["stdin_write"] + +[[permission]] +identifier = "deny-stdin-write" +description = "Denies the stdin_write command without any pre-configured scope." +commands.deny = ["stdin_write"] diff --git a/plugins/shell/permissions/autogenerated/reference.md b/plugins/shell/permissions/autogenerated/reference.md new file mode 100644 index 0000000..87086a1 --- /dev/null +++ b/plugins/shell/permissions/autogenerated/reference.md @@ -0,0 +1,154 @@ +## Default Permission + +This permission set configures which +shell functionality is exposed by default. + +#### Granted Permissions + +It allows to use the `open` functionality with a reasonable +scope pre-configured. It will allow opening `http(s)://`, +`tel:` and `mailto:` links. + +#### This default permission set includes the following: + +- `allow-open` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`shell:allow-execute` + + + +Enables the execute command without any pre-configured scope. + +
+ +`shell:deny-execute` + + + +Denies the execute command without any pre-configured scope. + +
+ +`shell:allow-kill` + + + +Enables the kill command without any pre-configured scope. + +
+ +`shell:deny-kill` + + + +Denies the kill command without any pre-configured scope. + +
+ +`shell:allow-open` + + + +Enables the open command without any pre-configured scope. + +
+ +`shell:deny-open` + + + +Denies the open command without any pre-configured scope. + +
+ +`shell:allow-spawn` + + + +Enables the spawn command without any pre-configured scope. + +
+ +`shell:deny-spawn` + + + +Denies the spawn command without any pre-configured scope. + +
+ +`shell:allow-stdin-write` + + + +Enables the stdin_write command without any pre-configured scope. + +
+ +`shell:deny-stdin-write` + + + +Denies the stdin_write command without any pre-configured scope. + +
diff --git a/plugins/shell/permissions/default.toml b/plugins/shell/permissions/default.toml new file mode 100644 index 0000000..dba2ea2 --- /dev/null +++ b/plugins/shell/permissions/default.toml @@ -0,0 +1,15 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +This permission set configures which +shell functionality is exposed by default. + +#### Granted Permissions + +It allows to use the `open` functionality with a reasonable +scope pre-configured. It will allow opening `http(s)://`, +`tel:` and `mailto:` links. +""" + +permissions = ["allow-open"] diff --git a/plugins/shell/permissions/schemas/schema.json b/plugins/shell/permissions/schemas/schema.json new file mode 100644 index 0000000..9a19898 --- /dev/null +++ b/plugins/shell/permissions/schemas/schema.json @@ -0,0 +1,366 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/shell/rollup.config.js b/plugins/shell/rollup.config.js new file mode 100644 index 0000000..a7dbd4f --- /dev/null +++ b/plugins/shell/rollup.config.js @@ -0,0 +1,22 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' +import terser from '@rollup/plugin-terser' + +export default createConfig({ + additionalConfigs: { + input: 'guest-js/init.ts', + output: { + file: 'src/init-iife.js', + format: 'iife' + }, + plugins: [typescript(), terser(), nodeResolve()], + onwarn: (warning) => { + throw Object.assign(new Error(), warning) + } + } +}) diff --git a/plugins/shell/src/commands.rs b/plugins/shell/src/commands.rs new file mode 100644 index 0000000..0facce7 --- /dev/null +++ b/plugins/shell/src/commands.rs @@ -0,0 +1,320 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{collections::HashMap, future::Future, path::PathBuf, pin::Pin, string::FromUtf8Error}; + +use encoding_rs::Encoding; +use serde::{Deserialize, Serialize}; +use tauri::{ + ipc::{Channel, CommandScope, GlobalScope}, + Manager, Runtime, State, Window, +}; + +#[allow(deprecated)] +use crate::open::Program; +use crate::{ + process::{CommandEvent, TerminatedPayload}, + scope::ExecuteArgs, + Shell, +}; + +type ChildId = u32; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", content = "payload")] +#[non_exhaustive] +pub enum JSCommandEvent { + /// Stderr bytes until a newline (\n) or carriage return (\r) is found. + Stderr(Buffer), + /// Stdout bytes until a newline (\n) or carriage return (\r) is found. + Stdout(Buffer), + /// An error happened waiting for the command to finish or converting the stdout/stderr bytes to an UTF-8 string. + Error(String), + /// Command process terminated. + Terminated(TerminatedPayload), +} + +fn get_event_buffer(line: Vec, encoding: EncodingWrapper) -> Result { + match encoding { + EncodingWrapper::Text(character_encoding) => match character_encoding { + Some(encoding) => Ok(Buffer::Text( + encoding.decode_with_bom_removal(&line).0.into(), + )), + None => String::from_utf8(line).map(Buffer::Text), + }, + EncodingWrapper::Raw => Ok(Buffer::Raw(line)), + } +} + +impl JSCommandEvent { + pub fn new(event: CommandEvent, encoding: EncodingWrapper) -> Self { + match event { + CommandEvent::Terminated(payload) => JSCommandEvent::Terminated(payload), + CommandEvent::Error(error) => JSCommandEvent::Error(error), + CommandEvent::Stderr(line) => get_event_buffer(line, encoding) + .map(JSCommandEvent::Stderr) + .unwrap_or_else(|e| JSCommandEvent::Error(e.to_string())), + CommandEvent::Stdout(line) => get_event_buffer(line, encoding) + .map(JSCommandEvent::Stdout) + .unwrap_or_else(|e| JSCommandEvent::Error(e.to_string())), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +#[allow(missing_docs)] +pub enum Buffer { + Text(String), + Raw(Vec), +} + +#[derive(Debug, Copy, Clone)] +pub enum EncodingWrapper { + Raw, + Text(Option<&'static Encoding>), +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandOptions { + #[serde(default)] + sidecar: bool, + cwd: Option, + // by default we don't add any env variables to the spawned process + // but the env is an `Option` so when it's `None` we clear the env. + #[serde(default = "default_env")] + env: Option>, + // Character encoding for stdout/stderr + encoding: Option, +} + +#[allow(clippy::unnecessary_wraps)] +fn default_env() -> Option> { + Some(HashMap::default()) +} + +#[inline(always)] +fn prepare_cmd( + window: Window, + program: String, + args: ExecuteArgs, + options: CommandOptions, + command_scope: CommandScope, + global_scope: GlobalScope, +) -> crate::Result<(crate::process::Command, EncodingWrapper)> { + let scope = crate::scope::ShellScope { + scopes: command_scope + .allows() + .iter() + .chain(global_scope.allows()) + .collect(), + }; + + let mut command = if options.sidecar { + let program = PathBuf::from(program); + let program_as_string = program.display().to_string(); + let has_extension = program.extension().is_some_and(|ext| ext == "exe"); + let program_no_ext_as_string = if has_extension { + program.with_extension("").display().to_string() + } else { + program_as_string.clone() + }; + let configured_sidecar = window + .config() + .bundle + .external_bin + .as_ref() + .and_then(|bins| { + bins.iter() + .find(|b| b == &&program_as_string || b == &&program_no_ext_as_string) + }) + .cloned(); + if let Some(sidecar) = configured_sidecar { + scope.prepare_sidecar(&program.to_string_lossy(), &sidecar, args)? + } else { + return Err(crate::Error::SidecarNotAllowed(program)); + } + } else { + match scope.prepare(&program, args) { + Ok(cmd) => cmd, + Err(e) => { + #[cfg(debug_assertions)] + eprintln!("{e}"); + return Err(crate::Error::ProgramNotAllowed(PathBuf::from(program))); + } + } + }; + if let Some(cwd) = options.cwd { + command = command.current_dir(cwd); + } + if let Some(env) = options.env { + command = command.envs(env); + } else { + command = command.env_clear(); + } + + let encoding = match options.encoding { + Option::None => EncodingWrapper::Text(None), + Some(encoding) => match encoding.as_str() { + "raw" => { + command = command.set_raw_out(true); + EncodingWrapper::Raw + } + _ => { + if let Some(text_encoding) = Encoding::for_label(encoding.as_bytes()) { + EncodingWrapper::Text(Some(text_encoding)) + } else { + return Err(crate::Error::UnknownEncoding(encoding)); + } + } + }, + }; + + Ok((command, encoding)) +} + +#[derive(Serialize)] +#[serde(untagged)] +enum Output { + String(String), + Raw(Vec), +} + +#[derive(Serialize)] +pub struct ChildProcessReturn { + code: Option, + signal: Option, + stdout: Output, + stderr: Output, +} + +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn execute( + window: Window, + program: String, + args: ExecuteArgs, + options: CommandOptions, + command_scope: CommandScope, + global_scope: GlobalScope, +) -> crate::Result { + let (command, encoding) = + prepare_cmd(window, program, args, options, command_scope, global_scope)?; + + let mut command: std::process::Command = command.into(); + let output = command.output()?; + + let (stdout, stderr) = match encoding { + EncodingWrapper::Text(Some(encoding)) => ( + Output::String(encoding.decode_with_bom_removal(&output.stdout).0.into()), + Output::String(encoding.decode_with_bom_removal(&output.stderr).0.into()), + ), + EncodingWrapper::Text(None) => ( + Output::String(String::from_utf8(output.stdout)?), + Output::String(String::from_utf8(output.stderr)?), + ), + EncodingWrapper::Raw => (Output::Raw(output.stdout), Output::Raw(output.stderr)), + }; + + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + + Ok(ChildProcessReturn { + code: output.status.code(), + #[cfg(windows)] + signal: None, + #[cfg(unix)] + signal: output.status.signal(), + stdout, + stderr, + }) +} + +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub fn spawn( + window: Window, + shell: State<'_, Shell>, + program: String, + args: ExecuteArgs, + on_event: Channel, + options: CommandOptions, + command_scope: CommandScope, + global_scope: GlobalScope, +) -> crate::Result { + let (command, encoding) = + prepare_cmd(window, program, args, options, command_scope, global_scope)?; + + let (mut rx, child) = command.spawn()?; + + let pid = child.pid(); + shell.children.lock().unwrap().insert(pid, child); + let children = shell.children.clone(); + + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + if matches!(event, crate::process::CommandEvent::Terminated(_)) { + children.lock().unwrap().remove(&pid); + }; + let js_event = JSCommandEvent::new(event, encoding); + + if on_event.send(js_event.clone()).is_err() { + fn send<'a>( + on_event: &'a Channel, + js_event: &'a JSCommandEvent, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_millis(15)).await; + if on_event.send(js_event.clone()).is_err() { + send(on_event, js_event).await; + } + }) + } + send(&on_event, &js_event).await; + } + } + }); + + Ok(pid) +} + +#[tauri::command] +pub fn stdin_write( + _window: Window, + shell: State<'_, Shell>, + pid: ChildId, + buffer: Buffer, +) -> crate::Result<()> { + if let Some(child) = shell.children.lock().unwrap().get_mut(&pid) { + match buffer { + Buffer::Text(t) => child.write(t.as_bytes())?, + Buffer::Raw(r) => child.write(&r)?, + } + } + Ok(()) +} + +#[tauri::command] +pub fn kill( + _window: Window, + shell: State<'_, Shell>, + pid: ChildId, +) -> crate::Result<()> { + if let Some(child) = shell.children.lock().unwrap().remove(&pid) { + child.kill()?; + } + Ok(()) +} + +#[allow(deprecated)] +#[tauri::command] +pub async fn open( + _window: Window, + shell: State<'_, Shell>, + path: String, + with: Option, +) -> crate::Result<()> { + crate::open::open(Some(&shell.open_scope), path, with) +} diff --git a/plugins/shell/src/config.rs b/plugins/shell/src/config.rs new file mode 100644 index 0000000..1523c08 --- /dev/null +++ b/plugins/shell/src/config.rs @@ -0,0 +1,39 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; + +/// Configuration for the shell plugin. +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + /// Open URL with the user's default application. + #[serde(default)] + pub open: ShellAllowlistOpen, +} + +/// Defines the `shell > open` api scope. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +#[derive(Default)] +pub enum ShellAllowlistOpen { + /// Shell open API allowlist is not defined by the user. + /// In this case we add the default validation regex (same as [`Self::Flag(true)`]). + #[default] + Unset, + /// If the shell open API should be enabled. + /// + /// If enabled, the default validation regex (`^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`) is used. + Flag(bool), + + /// Enable the shell open API, with a custom regex that the opened path must match against. + /// + /// The regex string is automatically surrounded by `^...$` to match the full string. + /// For example the `https?://\w+` regex would be registered as `^https?://\w+$`. + /// + /// If using a custom regex to support a non-http(s) schema, care should be used to prevent values + /// that allow flag-like strings to pass validation. e.g. `--enable-debugging`, `-i`, `/R`. + Validate(String), +} diff --git a/plugins/shell/src/error.rs b/plugins/shell/src/error.rs new file mode 100644 index 0000000..1e2edcf --- /dev/null +++ b/plugins/shell/src/error.rs @@ -0,0 +1,56 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +/// Errors returned by the shell plugin. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to invoke the mobile plugin implementation. + /// + /// Only available on mobile. + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// I/O error, usually raised when spawning a process or reading its output. + #[error(transparent)] + Io(#[from] std::io::Error), + /// The path of the current executable has no parent directory, + /// so the path of a sidecar program could not be resolved. + #[error("current executable path has no parent")] + CurrentExeHasNoParent, + /// The string does not match any of the known programs + /// accepted by the deprecated `open` API. + #[error("unknown program {0}")] + UnknownProgramName(String), + /// The command is not allowed by the configured shell scope. + #[error(transparent)] + Scope(#[from] crate::scope::Error), + /// Sidecar not allowed by the configuration. + #[error("sidecar not configured under `tauri.conf.json > bundle > externalBin`: {0}")] + SidecarNotAllowed(PathBuf), + /// Program not allowed by the scope. + #[error("program not allowed on the configured shell scope: {0}")] + ProgramNotAllowed(PathBuf), + /// The `encoding` spawn option is neither `raw` nor a label of a known character encoding. + #[error("unknown encoding {0}")] + UnknownEncoding(String), + /// JSON error. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// Utf8 error. + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/shell/src/init-iife.js b/plugins/shell/src/init-iife.js new file mode 100644 index 0000000..c584490 --- /dev/null +++ b/plugins/shell/src/init-iife.js @@ -0,0 +1 @@ +!function(){"use strict";async function e(e,t={},n){return window.__TAURI_INTERNALS__.invoke(e,t,n)}function t(){document.querySelector("body")?.addEventListener("click",function(t){let n=t.target;for(;n;){if(n.matches("a")){const r=n;""!==r.href&&["http://","https://","mailto:","tel:"].some(e=>r.href.startsWith(e))&&"_blank"===r.target&&(e("plugin:shell|open",{path:r.href}),t.preventDefault());break}n=n.parentElement}})}"function"==typeof SuppressedError&&SuppressedError,"complete"===document.readyState||"interactive"===document.readyState?t():window.addEventListener("DOMContentLoaded",t,!0)}(); diff --git a/plugins/shell/src/lib.rs b/plugins/shell/src/lib.rs new file mode 100644 index 0000000..2615b12 --- /dev/null +++ b/plugins/shell/src/lib.rs @@ -0,0 +1,195 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Access the system shell. Allows you to spawn child processes and manage files and URLs using their default application. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use std::{ + collections::HashMap, + ffi::OsStr, + path::Path, + sync::{Arc, Mutex}, +}; + +use process::{Command, CommandChild}; +use regex::Regex; +use tauri::{ + plugin::{Builder, TauriPlugin}, + AppHandle, Manager, RunEvent, Runtime, +}; + +mod commands; +mod config; +mod error; +#[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] +#[allow(deprecated)] +pub mod open; +/// Types and helpers to spawn and interact with child processes. +pub mod process; +mod scope; +mod scope_entry; + +pub use error::Error; +type Result = std::result::Result; + +#[cfg(mobile)] +use tauri::plugin::PluginHandle; +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.shell"; +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_shell); + +type ChildStore = Arc>>; + +/// Access to the shell APIs. +/// +/// Get an instance of this type with [`ShellExt::shell`]. +pub struct Shell { + #[allow(dead_code)] + app: AppHandle, + #[cfg(mobile)] + mobile_plugin_handle: PluginHandle, + open_scope: scope::OpenScope, + children: ChildStore, +} + +impl Shell { + /// Creates a new Command for launching the given program. + pub fn command(&self, program: impl AsRef) -> Command { + Command::new(program) + } + + /// Creates a new Command for launching the given sidecar program. + /// + /// A sidecar program is a embedded external binary in order to make your application work + /// or to prevent users having to install additional dependencies (e.g. Node.js, Python, etc). + pub fn sidecar(&self, program: impl AsRef) -> Result { + Command::new_sidecar(program) + } + + /// Open a (url) path with a default or specific browser opening program. + /// + /// See [`crate::open::open`] for how it handles security-related measures. + #[cfg(desktop)] + #[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] + #[allow(deprecated)] + pub fn open(&self, path: impl Into, with: Option) -> Result<()> { + open::open(None, path.into(), with) + } + + /// Open a (url) path with a default or specific browser opening program. + /// + /// See [`crate::open::open`] for how it handles security-related measures. + #[cfg(mobile)] + #[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] + pub fn open(&self, path: impl Into, _with: Option) -> Result<()> { + self.mobile_plugin_handle + .run_mobile_plugin("open", path.into()) + .map_err(Into::into) + } +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], +/// [`tauri::Webview`] and [`tauri::Window`] to access the shell APIs. +pub trait ShellExt { + /// Gets the shell APIs. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_shell::ShellExt; + /// + /// async fn run_echo(app: &tauri::AppHandle) { + /// let output = app.shell().command("echo").args(["hello"]).output().await.unwrap(); + /// println!("{}", String::from_utf8_lossy(&output.stdout)); + /// } + /// ``` + fn shell(&self) -> &Shell; +} + +impl> ShellExt for T { + fn shell(&self) -> &Shell { + self.state::>().inner() + } +} + +/// Initializes the shell plugin. +/// +/// The plugin state can be accessed with [`ShellExt::shell`], +/// and all spawned child processes are killed when the application exits. +/// +/// # Examples +/// +/// ```no_run +/// fn setup(builder: tauri::Builder) -> tauri::Builder { +/// builder.plugin(tauri_plugin_shell::init()) +/// } +/// ``` +pub fn init() -> TauriPlugin> { + Builder::>::new("shell") + .js_init_script(include_str!("init-iife.js").to_string()) + .invoke_handler(tauri::generate_handler![ + commands::execute, + commands::spawn, + commands::stdin_write, + commands::kill, + commands::open + ]) + .setup(|app, api| { + let default_config = config::Config::default(); + let config = api.config().as_ref().unwrap_or(&default_config); + + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "ShellPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_shell)?; + + app.manage(Shell { + app: app.clone(), + children: Default::default(), + open_scope: open_scope(&config.open), + + #[cfg(mobile)] + mobile_plugin_handle: handle, + }); + Ok(()) + }) + .on_event(|app, event| { + if let RunEvent::Exit = event { + let shell = app.state::>(); + let children = { + let mut lock = shell.children.lock().unwrap(); + std::mem::take(&mut *lock) + }; + for child in children.into_values() { + let _ = child.kill(); + } + } + }) + .build() +} + +fn open_scope(open: &config::ShellAllowlistOpen) -> scope::OpenScope { + let shell_scope_open = match open { + config::ShellAllowlistOpen::Flag(false) => None, + // we want to add a basic regex validation even if the config is not set + config::ShellAllowlistOpen::Unset | config::ShellAllowlistOpen::Flag(true) => { + Some(Regex::new(r"^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+").unwrap()) + } + config::ShellAllowlistOpen::Validate(validator) => { + let regex = format!("^{validator}$"); + let validator = + Regex::new(®ex).unwrap_or_else(|e| panic!("invalid regex {regex}: {e}")); + Some(validator) + } + }; + + scope::OpenScope { + open: shell_scope_open, + } +} diff --git a/plugins/shell/src/open.rs b/plugins/shell/src/open.rs new file mode 100644 index 0000000..6958a83 --- /dev/null +++ b/plugins/shell/src/open.rs @@ -0,0 +1,138 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Types and functions related to shell. + +use serde::{Deserialize, Deserializer}; + +use crate::scope::OpenScope; +use std::str::FromStr; + +/// Program to use on the [`open()`] call. +#[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] +pub enum Program { + /// Use the `open` program. + Open, + /// Use the `start` program. + Start, + /// Use the `xdg-open` program. + XdgOpen, + /// Use the `gio` program. + Gio, + /// Use the `gnome-open` program. + GnomeOpen, + /// Use the `kde-open` program. + KdeOpen, + /// Use the `wslview` program. + WslView, + /// Use the `Firefox` program. + Firefox, + /// Use the `Google Chrome` program. + Chrome, + /// Use the `Chromium` program. + Chromium, + /// Use the `Safari` program. + Safari, +} + +impl FromStr for Program { + type Err = super::Error; + + fn from_str(s: &str) -> Result { + let p = match s.to_lowercase().as_str() { + "open" => Self::Open, + "start" => Self::Start, + "xdg-open" => Self::XdgOpen, + "gio" => Self::Gio, + "gnome-open" => Self::GnomeOpen, + "kde-open" => Self::KdeOpen, + "wslview" => Self::WslView, + "firefox" => Self::Firefox, + "chrome" | "google chrome" => Self::Chrome, + "chromium" => Self::Chromium, + "safari" => Self::Safari, + _ => return Err(crate::Error::UnknownProgramName(s.to_string())), + }; + Ok(p) + } +} + +impl<'de> Deserialize<'de> for Program { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Program::from_str(&s).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +impl Program { + pub(crate) fn name(self) -> &'static str { + match self { + Self::Open => "open", + Self::Start => "start", + Self::XdgOpen => "xdg-open", + Self::Gio => "gio", + Self::GnomeOpen => "gnome-open", + Self::KdeOpen => "kde-open", + Self::WslView => "wslview", + + #[cfg(target_os = "macos")] + Self::Firefox => "Firefox", + #[cfg(not(target_os = "macos"))] + Self::Firefox => "firefox", + + #[cfg(target_os = "macos")] + Self::Chrome => "Google Chrome", + #[cfg(not(target_os = "macos"))] + Self::Chrome => "google-chrome", + + #[cfg(target_os = "macos")] + Self::Chromium => "Chromium", + #[cfg(not(target_os = "macos"))] + Self::Chromium => "chromium", + + #[cfg(target_os = "macos")] + Self::Safari => "Safari", + #[cfg(not(target_os = "macos"))] + Self::Safari => "safari", + } + } +} + +/// Opens path or URL with the program specified in `with`, or system default if `None`. +/// +/// The path will be matched against the shell open validation regex, defaulting to `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`. +/// A custom validation regex may be supplied in the config in `plugins > shell > scope > open`. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tauri_plugin_shell::ShellExt; +/// tauri::Builder::default() +/// .setup(|app| { +/// // open the given URL on the system default browser +/// app.shell().open("https://github.com/tauri-apps/tauri", None)?; +/// Ok(()) +/// }); +/// ``` +#[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] +pub fn open>( + scope: Option<&OpenScope>, + path: P, + with: Option, +) -> crate::Result<()> { + // validate scope if we have any (JS calls) + if let Some(scope) = scope { + scope.open(path.as_ref(), with).map_err(Into::into) + } else { + // when running directly from Rust code we don't need to validate the path + match with.map(Program::name) { + Some(program) => ::open::with_detached(path.as_ref(), program), + None => ::open::that_detached(path.as_ref()), + } + .map_err(Into::into) + } +} diff --git a/plugins/shell/src/process/mod.rs b/plugins/shell/src/process/mod.rs new file mode 100644 index 0000000..7a1c2db --- /dev/null +++ b/plugins/shell/src/process/mod.rs @@ -0,0 +1,658 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + ffi::OsStr, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Command as StdCommand, Stdio}, + sync::{Arc, RwLock}, + thread::spawn, +}; + +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const NEWLINE_BYTE: u8 = b'\n'; + +use tauri::async_runtime::{block_on as block_on_task, channel, Receiver, Sender}; + +pub use encoding_rs::Encoding; +use os_pipe::{pipe, PipeReader, PipeWriter}; +use serde::Serialize; +use shared_child::SharedChild; +use tauri::utils::platform; + +/// Payload for the [`CommandEvent::Terminated`] command event. +#[derive(Debug, Clone, Serialize)] +pub struct TerminatedPayload { + /// Exit code of the process. + pub code: Option, + /// If the process was terminated by a signal, represents that signal. + pub signal: Option, +} + +/// A event sent to the command callback. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum CommandEvent { + /// If configured for raw output, all bytes written to stderr. + /// Otherwise, bytes until a newline (\n) or carriage return (\r) is found. + Stderr(Vec), + /// If configured for raw output, all bytes written to stdout. + /// Otherwise, bytes until a newline (\n) or carriage return (\r) is found. + Stdout(Vec), + /// An error happened waiting for the command to finish or converting the stdout/stderr bytes to a UTF-8 string. + Error(String), + /// Command process terminated. + Terminated(TerminatedPayload), +} + +/// The type to spawn commands. +#[derive(Debug)] +pub struct Command { + cmd: StdCommand, + raw_out: bool, +} + +/// Spawned child process. +#[derive(Debug)] +pub struct CommandChild { + inner: Arc, + stdin_writer: PipeWriter, +} + +impl CommandChild { + /// Writes to process stdin. + pub fn write(&mut self, buf: &[u8]) -> crate::Result<()> { + self.stdin_writer.write_all(buf)?; + Ok(()) + } + + /// Sends a kill signal to the child. + pub fn kill(self) -> crate::Result<()> { + self.inner.kill()?; + Ok(()) + } + + /// Returns the process pid. + pub fn pid(&self) -> u32 { + self.inner.id() + } +} + +/// Describes the result of a process after it has terminated. +#[derive(Debug)] +pub struct ExitStatus { + // This field is intentionally left private. + // See: https://github.com/tauri-apps/plugins-workspace/pull/3115. + code: Option, +} + +impl ExitStatus { + /// Returns the exit code of the process, if any. + pub fn code(&self) -> Option { + self.code + } + + /// Returns true if exit status is zero. Signal termination is not considered a success, and success is defined as a zero exit status. + pub fn success(&self) -> bool { + self.code == Some(0) + } +} + +/// The output of a finished process. +#[derive(Debug)] +pub struct Output { + /// The status (exit code) of the process. + pub status: ExitStatus, + /// The data that the process wrote to stdout. + pub stdout: Vec, + /// The data that the process wrote to stderr. + pub stderr: Vec, +} + +fn relative_command_path(command: &Path) -> crate::Result { + let exe_path = platform::current_exe()?; + + let exe_dir = exe_path + .parent() + .ok_or(crate::Error::CurrentExeHasNoParent)?; + + // If a test is being run, the executable is in the "deps" directory, so we need to go up one level. + let base_dir = if exe_dir.ends_with("deps") { + exe_dir.parent().unwrap_or(exe_dir) + } else { + exe_dir + }; + + let mut command_path = base_dir.join(command); + + #[cfg(windows)] + { + let already_exe = command_path.extension().is_some_and(|ext| ext == "exe"); + if !already_exe { + // do not use with_extension to retain dots in the command filename + command_path.as_mut_os_string().push(".exe"); + } + } + + #[cfg(not(windows))] + { + if command_path.extension().is_some_and(|ext| ext == "exe") { + command_path.set_extension(""); + } + } + + Ok(command_path) +} + +impl From for StdCommand { + fn from(cmd: Command) -> StdCommand { + cmd.cmd + } +} + +impl Command { + pub(crate) fn new>(program: S) -> Self { + log::debug!( + "Creating sidecar {}", + program.as_ref().to_str().unwrap_or("") + ); + let mut command = StdCommand::new(program); + + command.stdout(Stdio::piped()); + command.stdin(Stdio::piped()); + command.stderr(Stdio::piped()); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + Self { + cmd: command, + raw_out: false, + } + } + + pub(crate) fn new_sidecar>(program: S) -> crate::Result { + Ok(Self::new(relative_command_path(program.as_ref())?)) + } + + /// Appends an argument to the command. + #[must_use] + pub fn arg>(mut self, arg: S) -> Self { + self.cmd.arg(arg); + self + } + + /// Appends arguments to the command. + #[must_use] + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.cmd.args(args); + self + } + + /// Clears the entire environment map for the child process. + #[must_use] + pub fn env_clear(mut self) -> Self { + self.cmd.env_clear(); + self + } + + /// Inserts or updates an explicit environment variable mapping. + #[must_use] + pub fn env(mut self, key: K, value: V) -> Self + where + K: AsRef, + V: AsRef, + { + self.cmd.env(key, value); + self + } + + /// Adds or updates multiple environment variable mappings. + #[must_use] + pub fn envs(mut self, envs: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + self.cmd.envs(envs); + self + } + + /// Sets the working directory for the child process. + #[must_use] + pub fn current_dir>(mut self, current_dir: P) -> Self { + self.cmd.current_dir(current_dir); + self + } + + /// Configures the reader to output bytes from the child process exactly as received + pub fn set_raw_out(mut self, raw_out: bool) -> Self { + self.raw_out = raw_out; + self + } + + /// Spawns the command. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_shell::{process::CommandEvent, ShellExt}; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// tauri::async_runtime::spawn(async move { + /// let (mut rx, mut child) = handle + /// .shell() + /// .command("cargo") + /// .args(["tauri", "dev"]) + /// .spawn() + /// .expect("Failed to spawn cargo"); + /// + /// let mut i = 0; + /// while let Some(event) = rx.recv().await { + /// if let CommandEvent::Stdout(line) = event { + /// println!("got: {}", String::from_utf8(line).unwrap()); + /// i += 1; + /// if i == 4 { + /// child.write("message from Rust\n".as_bytes()).unwrap(); + /// i = 0; + /// } + /// } + /// } + /// }); + /// Ok(()) + /// }); + /// ``` + /// + /// Depending on the command you spawn, it might output in a specific encoding, to parse the output lines in this case: + /// + /// ```rust,no_run + /// use tauri_plugin_shell::{process::{CommandEvent, Encoding}, ShellExt}; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// tauri::async_runtime::spawn(async move { + /// let (mut rx, mut child) = handle + /// .shell() + /// .command("some-program") + /// .arg("some-arg") + /// .spawn() + /// .expect("Failed to spawn some-program"); + /// + /// let encoding = Encoding::for_label(b"windows-1252").unwrap(); + /// while let Some(event) = rx.recv().await { + /// if let CommandEvent::Stdout(line) = event { + /// let (decoded, _, _) = encoding.decode(&line); + /// println!("got: {decoded}"); + /// } + /// } + /// }); + /// Ok(()) + /// }); + /// ``` + pub fn spawn(self) -> crate::Result<(Receiver, CommandChild)> { + let raw = self.raw_out; + let mut command: StdCommand = self.into(); + let (stdout_reader, stdout_writer) = pipe()?; + let (stderr_reader, stderr_writer) = pipe()?; + let (stdin_reader, stdin_writer) = pipe()?; + command.stdout(stdout_writer); + command.stderr(stderr_writer); + command.stdin(stdin_reader); + + let shared_child = SharedChild::spawn(&mut command)?; + let child = Arc::new(shared_child); + let child_ = child.clone(); + let guard = Arc::new(RwLock::new(())); + + let (tx, rx) = channel(1); + + spawn_pipe_reader( + tx.clone(), + guard.clone(), + stdout_reader, + CommandEvent::Stdout, + raw, + ); + spawn_pipe_reader( + tx.clone(), + guard.clone(), + stderr_reader, + CommandEvent::Stderr, + raw, + ); + + spawn(move || { + let _ = match child_.wait() { + Ok(status) => { + let _l = guard.write().unwrap(); + block_on_task(async move { + tx.send(CommandEvent::Terminated(TerminatedPayload { + code: status.code(), + #[cfg(windows)] + signal: None, + #[cfg(unix)] + signal: status.signal(), + })) + .await + }) + } + Err(e) => { + let _l = guard.write().unwrap(); + block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await }) + } + }; + }); + + Ok(( + rx, + CommandChild { + inner: child, + stdin_writer, + }, + )) + } + + /// Executes a command as a child process, waiting for it to finish and collecting its exit status. + /// Stdin, stdout and stderr are ignored. + /// + /// # Examples + /// ```rust,no_run + /// use tauri_plugin_shell::ShellExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let status = tauri::async_runtime::block_on(async move { app.shell().command("which").args(["ls"]).status().await.unwrap() }); + /// println!("`which` finished with status: {:?}", status.code()); + /// Ok(()) + /// }); + /// ``` + pub async fn status(self) -> crate::Result { + let (mut rx, _child) = self.spawn()?; + let mut code = None; + #[allow(clippy::collapsible_match)] + while let Some(event) = rx.recv().await { + if let CommandEvent::Terminated(payload) = event { + code = payload.code; + } + } + Ok(ExitStatus { code }) + } + + /// Executes the command as a child process, waiting for it to finish and collecting all of its output. + /// Stdin is ignored. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri_plugin_shell::ShellExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let output = tauri::async_runtime::block_on(async move { app.shell().command("echo").args(["TAURI"]).output().await.unwrap() }); + /// assert!(output.status.success()); + /// assert_eq!(String::from_utf8(output.stdout).unwrap(), "TAURI"); + /// Ok(()) + /// }); + /// ``` + pub async fn output(self) -> crate::Result { + let (mut rx, _child) = self.spawn()?; + + let mut code = None; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + code = payload.code; + } + CommandEvent::Stdout(line) => { + stdout.extend(line); + stdout.push(NEWLINE_BYTE); + } + CommandEvent::Stderr(line) => { + stderr.extend(line); + stderr.push(NEWLINE_BYTE); + } + CommandEvent::Error(_) => {} + } + } + Ok(Output { + status: ExitStatus { code }, + stdout, + stderr, + }) + } +} + +fn read_raw_bytes) -> CommandEvent + Send + Copy + 'static>( + mut reader: BufReader, + tx: Sender, + wrapper: F, +) { + loop { + let result = reader.fill_buf(); + match result { + Ok(buf) => { + let length = buf.len(); + if length == 0 { + break; + } + let tx_ = tx.clone(); + let _ = block_on_task(async move { tx_.send(wrapper(buf.to_vec())).await }); + reader.consume(length); + } + Err(e) => { + let tx_ = tx.clone(); + let _ = block_on_task( + async move { tx_.send(CommandEvent::Error(e.to_string())).await }, + ); + } + } + } +} + +fn read_line) -> CommandEvent + Send + Copy + 'static>( + mut reader: BufReader, + tx: Sender, + wrapper: F, +) { + loop { + let mut buf = Vec::new(); + match tauri::utils::io::read_line(&mut reader, &mut buf) { + Ok(n) => { + if n == 0 { + break; + } + let tx_ = tx.clone(); + let _ = block_on_task(async move { tx_.send(wrapper(buf)).await }); + } + Err(e) => { + let _ = + block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await }); + break; + } + } + } +} + +fn spawn_pipe_reader) -> CommandEvent + Send + Copy + 'static>( + tx: Sender, + guard: Arc>, + pipe_reader: PipeReader, + wrapper: F, + raw_out: bool, +) { + spawn(move || { + let _lock = guard.read().unwrap(); + let reader = BufReader::new(pipe_reader); + + if raw_out { + read_raw_bytes(reader, tx, wrapper); + } else { + read_line(reader, tx, wrapper); + } + }); +} + +// tests for the commands functions. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_command_path_resolves() { + let cwd_parent = platform::current_exe() + .unwrap() + .parent() + .unwrap() + .parent() // Go up once more to get out of the "deps" directory + .unwrap() + .to_owned(); + assert_eq!( + relative_command_path(Path::new("Tauri.Example")).unwrap(), + cwd_parent.join(if cfg!(windows) { + "Tauri.Example.exe" + } else { + "Tauri.Example" + }) + ); + assert_eq!( + relative_command_path(Path::new("Tauri.Example.exe")).unwrap(), + cwd_parent.join(if cfg!(windows) { + "Tauri.Example.exe" + } else { + "Tauri.Example" + }) + ); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_spawn_output() { + let cmd = Command::new("cat").args(["test/test.txt"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(0)); + } + CommandEvent::Stdout(line) => { + assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!"); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_spawn_raw_output() { + let cmd = Command::new("cat").args(["test/test.txt"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(0)); + } + CommandEvent::Stdout(line) => { + assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!"); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + // test the failure case + fn test_cmd_spawn_fail() { + let cmd = Command::new("cat").args(["test/"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(1)); + } + CommandEvent::Stderr(line) => { + assert_eq!( + String::from_utf8(line).unwrap(), + "cat: test/: Is a directory\n" + ); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + // test the failure case (raw encoding) + fn test_cmd_spawn_raw_fail() { + let cmd = Command::new("cat").args(["test/"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(1)); + } + CommandEvent::Stderr(line) => { + assert_eq!( + String::from_utf8(line).unwrap(), + "cat: test/: Is a directory\n" + ); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_output_output() { + let cmd = Command::new("cat").args(["test/test.txt"]); + let output = tauri::async_runtime::block_on(cmd.output()).unwrap(); + + assert_eq!(String::from_utf8(output.stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "This is a test doc!\n" + ); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_output_output_fail() { + let cmd = Command::new("cat").args(["test/"]); + let output = tauri::async_runtime::block_on(cmd.output()).unwrap(); + + assert_eq!(String::from_utf8(output.stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(output.stderr).unwrap(), + "cat: test/: Is a directory\n\n" + ); + } +} diff --git a/plugins/shell/src/scope.rs b/plugins/shell/src/scope.rs new file mode 100644 index 0000000..35fdeaf --- /dev/null +++ b/plugins/shell/src/scope.rs @@ -0,0 +1,322 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::Arc; + +#[allow(deprecated)] +use crate::open::Program; +use crate::process::Command; + +use regex::Regex; +use tauri::ipc::ScopeObject; +use tauri::Manager; + +/// Allowed representation of `Execute` command arguments. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ExecuteArgs { + /// No arguments + None, + + /// A single string argument + Single(String), + + /// Multiple string arguments + List(Vec), +} + +impl ExecuteArgs { + /// Whether the argument list is empty or not. + pub fn is_empty(&self) -> bool { + match self { + Self::None => true, + Self::Single(s) if s.is_empty() => true, + Self::List(l) => l.is_empty(), + _ => false, + } + } +} + +impl From<()> for ExecuteArgs { + fn from(_: ()) -> Self { + Self::None + } +} + +impl From for ExecuteArgs { + fn from(string: String) -> Self { + Self::Single(string) + } +} + +impl From> for ExecuteArgs { + fn from(vec: Vec) -> Self { + Self::List(vec) + } +} + +/// A configured scoped shell command. +#[derive(Debug, Clone)] +pub struct ScopeAllowedCommand { + /// Name of the command (key). + pub name: String, + + /// The shell command to be called. + pub command: std::path::PathBuf, + + /// The arguments the command is allowed to be called with. + pub args: Option>, + + /// If this command is a sidecar command. + pub sidecar: bool, +} + +impl ScopeObject for ScopeAllowedCommand { + type Error = crate::Error; + fn deserialize( + app: &tauri::AppHandle, + raw: tauri::utils::acl::Value, + ) -> Result { + let scope = serde_json::from_value::(raw.into())?; + + let args = match scope.args.clone() { + crate::scope_entry::ShellAllowedArgs::Flag(true) => None, + crate::scope_entry::ShellAllowedArgs::Flag(false) => Some(Vec::new()), + crate::scope_entry::ShellAllowedArgs::List(list) => { + let list = list.into_iter().map(|arg| match arg { + crate::scope_entry::ShellAllowedArg::Fixed(fixed) => { + crate::scope::ScopeAllowedArg::Fixed(fixed) + } + crate::scope_entry::ShellAllowedArg::Var { validator, raw } => { + let regex = if raw { + validator + } else { + format!("^{validator}$") + }; + let validator = Regex::new(®ex) + .unwrap_or_else(|e| panic!("invalid regex {regex}: {e}")); + crate::scope::ScopeAllowedArg::Var { validator } + } + }); + Some(list.collect()) + } + }; + + let command = if let Ok(path) = app.path().parse(&scope.command) { + path + } else { + scope.command.clone() + }; + + Ok(Self { + name: scope.name, + command, + args, + sidecar: scope.sidecar, + }) + } +} + +/// A configured argument to a scoped shell command. +#[derive(Debug, Clone)] +pub enum ScopeAllowedArg { + /// A non-configurable argument. + Fixed(String), + + /// An argument with a value to be evaluated at runtime, must pass a regex validation. + Var { + /// The validation that the variable value must pass in order to be called. + validator: Regex, + }, +} + +impl ScopeAllowedArg { + /// If the argument is fixed. + pub fn is_fixed(&self) -> bool { + matches!(self, Self::Fixed(_)) + } +} + +/// Scope for the open command +pub struct OpenScope { + /// The validation regex that `shell > open` paths must match against. + /// When set to `None`, no values are accepted. + pub open: Option, +} + +/// Scope for shell process spawning. +#[derive(Clone)] +pub struct ShellScope<'a> { + /// All allowed commands, using their unique command name as the keys. + pub scopes: Vec<&'a Arc>, +} + +/// All errors that can happen while validating a scoped command. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// At least one argument did not pass input validation. + #[error("The scoped command was called with the improper sidecar flag set")] + BadSidecarFlag, + + /// The sidecar program validated but failed to find the sidecar path. + #[error( + "The scoped sidecar command was validated, but failed to create the path to the command: {0}" + )] + Sidecar(String), + + /// The named command was not found in the scoped config. + #[error("Scoped command {0} not found")] + NotFound(String), + + /// A command variable has no value set in the arguments. + #[error( + "Scoped command argument at position {0} must match regex validation {1} but it was not found" + )] + MissingVar(usize, String), + + /// At least one argument did not pass input validation. + #[error("Scoped command argument at position {index} was found, but failed regex validation {validation}")] + Validation { + /// Index of the variable. + index: usize, + + /// Regex that the variable value failed to match. + validation: String, + }, + + /// The format of the passed input does not match the expected shape. + /// + /// This can happen from passing a string or array of strings to a command that is expecting + /// named variables, and vice-versa. + #[error("Scoped command {0} received arguments in an unexpected format")] + InvalidInput(String), + + /// A generic IO error that occurs while executing specified shell commands. + #[error("Scoped shell IO error: {0}")] + Io(#[from] std::io::Error), +} + +impl OpenScope { + /// Open a path in the default (or specified) browser. + /// + /// The path is validated against the `plugins > shell > open` validation regex, which + /// defaults to `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`. + #[allow(deprecated)] + pub fn open(&self, path: &str, with: Option) -> Result<(), Error> { + // ensure we pass validation if the configuration has one + if let Some(regex) = &self.open { + if !regex.is_match(path) { + return Err(Error::Validation { + index: 0, + validation: regex.as_str().into(), + }); + } + } else { + log::warn!("open() command called but the plugin configuration denies calls from JavaScript; set `tauri.conf.json > plugins > shell > open` to true or a validation regex string"); + return Err(Error::Validation { + index: 0, + validation: "tauri^".to_string(), // purposefully impossible regex + }); + } + + // The prevention of argument escaping is handled by the usage of std::process::Command::arg by + // the `open` dependency. This behavior should be re-confirmed during upgrades of `open`. + match with.map(Program::name) { + Some(program) => ::open::with_detached(path, program), + None => ::open::that_detached(path), + } + .map_err(Into::into) + } +} + +impl ShellScope<'_> { + /// Validates argument inputs and creates a Tauri sidecar [`Command`]. + pub fn prepare_sidecar( + &self, + command_name: &str, + command_script: &str, + args: ExecuteArgs, + ) -> Result { + self._prepare(command_name, args, Some(command_script)) + } + + /// Validates argument inputs and creates a Tauri [`Command`]. + pub fn prepare(&self, command_name: &str, args: ExecuteArgs) -> Result { + self._prepare(command_name, args, None) + } + + /// Validates argument inputs and creates a Tauri [`Command`]. + pub fn _prepare( + &self, + command_name: &str, + args: ExecuteArgs, + sidecar: Option<&str>, + ) -> Result { + let command = match self.scopes.iter().find(|s| s.name == command_name) { + Some(command) => command, + None => return Err(Error::NotFound(command_name.into())), + }; + + if command.sidecar != sidecar.is_some() { + return Err(Error::BadSidecarFlag); + } + + let args = match (&command.args, args) { + (None, ExecuteArgs::None) => Ok(vec![]), + (None, ExecuteArgs::List(list)) => Ok(list), + (None, ExecuteArgs::Single(string)) => Ok(vec![string]), + (Some(list), ExecuteArgs::List(args)) => list + .iter() + .enumerate() + .map(|(i, arg)| match arg { + ScopeAllowedArg::Fixed(fixed) => Ok(fixed.to_string()), + ScopeAllowedArg::Var { validator } => { + let value = args + .get(i) + .ok_or_else(|| Error::MissingVar(i, validator.to_string()))? + .to_string(); + if validator.is_match(&value) { + Ok(value) + } else { + Err(Error::Validation { + index: i, + validation: validator.to_string(), + }) + } + } + }) + .collect(), + (Some(list), arg) if arg.is_empty() && list.iter().all(ScopeAllowedArg::is_fixed) => { + list.iter() + .map(|arg| match arg { + ScopeAllowedArg::Fixed(fixed) => Ok(fixed.to_string()), + _ => unreachable!(), + }) + .collect() + } + (Some(list), _) if list.is_empty() => Err(Error::InvalidInput(command_name.into())), + (Some(_), _) => Err(Error::InvalidInput(command_name.into())), + }?; + + let command_s = sidecar + .map(|s| { + std::path::PathBuf::from(s) + .components() + .next_back() + .unwrap() + .as_os_str() + .to_string_lossy() + .into_owned() + }) + .unwrap_or_else(|| command.command.to_string_lossy().into_owned()); + let command = if command.sidecar { + Command::new_sidecar(command_s).map_err(|e| Error::Sidecar(e.to_string()))? + } else { + Command::new(command_s) + }; + + Ok(command.args(args)) + } +} diff --git a/plugins/shell/src/scope_entry.rs b/plugins/shell/src/scope_entry.rs new file mode 100644 index 0000000..8a70fff --- /dev/null +++ b/plugins/shell/src/scope_entry.rs @@ -0,0 +1,77 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::Error as DeError, Deserialize, Deserializer}; + +use std::path::PathBuf; + +/// A command allowed to be executed by the webview API. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct Entry { + pub(crate) name: String, + pub(crate) command: PathBuf, + pub(crate) args: ShellAllowedArgs, + pub(crate) sidecar: bool, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub(crate) struct EntryRaw { + pub(crate) name: String, + #[serde(rename = "cmd")] + pub(crate) command: Option, + #[serde(default)] + pub(crate) args: ShellAllowedArgs, + #[serde(default)] + pub(crate) sidecar: bool, +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let config = EntryRaw::deserialize(deserializer)?; + + if !config.sidecar && config.command.is_none() { + return Err(DeError::custom( + "The shell scope `command` value is required.", + )); + } + + Ok(Entry { + name: config.name, + command: config.command.unwrap_or_default(), + args: config.args, + sidecar: config.sidecar, + }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellAllowedArgs { + Flag(bool), + List(Vec), +} + +impl Default for ShellAllowedArgs { + fn default() -> Self { + Self::Flag(false) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellAllowedArg { + Fixed(String), + Var { + validator: String, + #[serde(default)] + raw: bool, + }, +} diff --git a/plugins/shell/test/test.txt b/plugins/shell/test/test.txt new file mode 100644 index 0000000..ec77307 --- /dev/null +++ b/plugins/shell/test/test.txt @@ -0,0 +1 @@ +This is a test doc! \ No newline at end of file diff --git a/plugins/shell/tsconfig.json b/plugins/shell/tsconfig.json new file mode 100644 index 0000000..4374468 --- /dev/null +++ b/plugins/shell/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "guest-js" + }, + "include": ["guest-js/*.ts"] +} diff --git a/plugins/single-instance/CHANGELOG.md b/plugins/single-instance/CHANGELOG.md new file mode 100644 index 0000000..3e4c94a --- /dev/null +++ b/plugins/single-instance/CHANGELOG.md @@ -0,0 +1,268 @@ +# Changelog + +## [2.4.5] + +- [`67cd25a1`](https://github.com/tauri-apps/plugins-workspace/commit/67cd25a10ce3deb1b935e725228e528766f24ad7) ([#3592](https://github.com/tauri-apps/plugins-workspace/pull/3592)) On Windows, the second instance now allows the first instance to bring its window to the front before exiting, so focusing a window from the callback no longer gets refused by Windows. + +## [2.4.4] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +### Dependencies + +- Upgraded to `deep-link@2.4.10` + +## \[2.4.3] + +- [`d1573877`](https://github.com/tauri-apps/plugins-workspace/commit/d1573877226e609461761aa538cd0ca4f24d22be) ([#3466](https://github.com/tauri-apps/plugins-workspace/pull/3466) by [@bajoca05](https://github.com/tauri-apps/plugins-workspace/../../bajoca05)) Fix blocked thread on the single-instance plugin for MacOS: replace standard `UnixListener` with `tokio::net::UnixListener`, so the task can yield. + +## \[2.4.2] + +### Dependencies + +- Upgraded to `deep-link@2.4.9` + +## \[2.4.1] + +### Dependencies + +- Upgraded to `deep-link@2.4.8` + +## \[2.4.0] + +### Dependencies + +- Upgraded to `deep-link@2.4.7` + +### fix + +- [`98e2c11e`](https://github.com/tauri-apps/plugins-workspace/commit/98e2c11eefc3ee562f1ed280efe7e8ea6ff0f3b0) ([#3194](https://github.com/tauri-apps/plugins-workspace/pull/3194) by [@mrquantumoff](https://github.com/tauri-apps/plugins-workspace/../../mrquantumoff)) **Breaking Change:** On Linux, the DBus ID/name will now be `.SingleInstance` instead of `org..SingleInstance` to follow DBus specifications. + + This will break the single-instance mechanism across different app versions if the app was installed multiple times. + + Added `dbus_id` builder method, which can be used to restore previous behavior. For a bundle identifier of `com.tauri.my-example` this would be `dbus_id("org.com_tauri_my_example")`. + +## \[2.3.7] + +### Dependencies + +- Upgraded to `deep-link@2.4.6` + +## \[2.3.6] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +### Dependencies + +- Upgraded to `deep-link@2.4.5` + +## \[2.3.5] + +### Dependencies + +- Upgraded to `deep-link@2.4.4` + +## \[2.3.4] + +### Dependencies + +- Upgraded to `deep-link@2.4.3` + +## \[2.3.3] + +### Dependencies + +- Upgraded to `deep-link@2.4.2` + +## \[2.3.2] + +### Dependencies + +- Upgraded to `deep-link@2.4.1` + +## \[2.3.1] + +- [`6f345870`](https://github.com/tauri-apps/plugins-workspace/commit/6f345870df4e7b187deb869df03b79858e03b4fe) ([#2860](https://github.com/tauri-apps/plugins-workspace/pull/2860) by [@MorpheusXAUT](https://github.com/tauri-apps/plugins-workspace/../../MorpheusXAUT)) Fix D-Bus name replacement logic on Linux to prevent multiple instances from acquiring the same well-known name.\ + This patch updates the `zbus` dependency to the latest compatible version (`^5.9`) and explicitly sets `RequestNameFlags` to ensure a second instance fails to acquire the D-Bus name when another one is already running. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +### Dependencies + +- Upgraded to `deep-link@2.4.0` + +## \[2.2.4] + +- [`dc84f8d8`](https://github.com/tauri-apps/plugins-workspace/commit/dc84f8d8bbaa70de3bb3185fbacb472993b996ef) ([#2609](https://github.com/tauri-apps/plugins-workspace/pull/2609) by [@Simon-Laux](https://github.com/tauri-apps/plugins-workspace/../../Simon-Laux)) fix `cwd` in single instance on macOS, which was the cwd of the first instance, instead of the second (like it is on windows and linux) + +### Dependencies + +- Upgraded to `deep-link@2.3.0` + +## \[2.2.3] + +### Dependencies + +- Upgraded to `deep-link@2.2.1` + +## \[2.2.2] + +- [`1ab5f157`](https://github.com/tauri-apps/plugins-workspace/commit/1ab5f1576333174095bc7dad4bef7a8576bb29ab) ([#2452](https://github.com/tauri-apps/plugins-workspace/pull/2452) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed `null pointer dereference` panic on rust nightly on Windows. + +## \[2.2.1] + +- [`da5c59e2`](https://github.com/tauri-apps/plugins-workspace/commit/da5c59e2fe879d177e3cfd52fcacce85440423cb) ([#2271](https://github.com/tauri-apps/plugins-workspace/pull/2271) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated `zbus` dependency to version 5. No API changes. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +### Dependencies + +- Upgraded to `deep-link@2.1.0` + +## \[2.0.2] + +### Dependencies + +- Upgraded to `deep-link@2.0.2` + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +### Dependencies + +- Upgraded to `deep-link@2.0.1` + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +### Dependencies + +- Upgraded to `deep-link@2.0.0` + +## \[2.0.0-rc.5] + +### Dependencies + +- Upgraded to `deep-link@2.0.0-rc.7` + +## \[2.0.0-rc.4] + +### Dependencies + +- Upgraded to `deep-link@2.0.0-rc.6` + +## \[2.0.0-rc.3] + +- [`b2269333`](https://github.com/tauri-apps/plugins-workspace/commit/b2269333e39afe32629a11763a8e25d0b12b132b) ([#1766](https://github.com/tauri-apps/plugins-workspace/pull/1766) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Put deep link integration behined a feature + +### Dependencies + +- Upgraded to `deep-link@2.0.0-rc.5` + +## \[2.0.0-rc.2] + +- [`64a6240f`](https://github.com/tauri-apps/plugins-workspace/commit/64a6240f79fcd52267c8d721b727ae695055d7ff) ([#1759](https://github.com/tauri-apps/plugins-workspace/pull/1759) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Integrate with the deep link plugin out of the box. + +### Dependencies + +- Upgraded to `deep-link@2.0.0-rc.4` + +## \[2.0.0-rc.1] + +- [`3c52f30e`](https://github.com/tauri-apps/plugins-workspace/commit/3c52f30ea4ec29c51f7021aa7871614d72e43258) ([#1665](https://github.com/tauri-apps/plugins-workspace/pull/1665) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Updated `windows-sys` crate to `0.59` +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.12] + +- [`e847cedc`](https://github.com/tauri-apps/plugins-workspace/commit/e847cedc1f46f3e7a2ad81ea579b620bc5b992d7) ([#1402](https://github.com/tauri-apps/plugins-workspace/pull/1402) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Use no default features on tauri for all plugins so that consumers can use `default-features = false` on tauri, note that this will still enable wry feature on iOS +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.11] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.10] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.9] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.8] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.7] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.6] + +- [`ed46dca`](https://github.com/tauri-apps/plugins-workspace/commit/ed46dca74ff3947dbbcb26a7b571c129bf925698) Added the `semver` feature flag to make the single instance mechanism only trigger for semver compatible versions. + +## \[2.0.0-beta.5] + +- [`dabac0e`](https://github.com/tauri-apps/plugins-workspace/commit/dabac0eedfd6e6d192c6c5a214e708b3c0223f6f)([#1035](https://github.com/tauri-apps/plugins-workspace/pull/1035)) Added implementation for MacOS. + +## \[2.0.0-beta.4] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.3] + +- [`2397ec5`](https://github.com/tauri-apps/plugins-workspace/commit/2397ec5937e594397e533925ccd257cae30b4cd1)([#1019](https://github.com/tauri-apps/plugins-workspace/pull/1019)) Fix doesn't shutdown immediately. +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.2] + +- [`6d1e621`](https://github.com/tauri-apps/plugins-workspace/commit/6d1e6218b5877ef91f589f790f7251acda9c9605)([#981](https://github.com/tauri-apps/plugins-workspace/pull/981)) Fix `zbus::blocking::connection::Builder` import. + +## \[2.0.0-beta.1] + +- [`14f381a`](https://github.com/tauri-apps/plugins-workspace/commit/14f381acf8fe690acecc676922c6f05939b95734) Update MSRV to 1.75. +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Add permissions. + +## \[2.0.0-alpha.6] + +- [`2cf8faa`](https://github.com/tauri-apps/plugins-workspace/commit/2cf8faa3e149af55eb86e5aba8ebfc54210ca703)([#839](https://github.com/tauri-apps/plugins-workspace/pull/839)) Update to tauri@alpha.20. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to tauri@alpha.18. + +## \[2.0.0-alpha.4] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to tauri@alpha.17. + +## \[2.0.0-alpha.3] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update MSRV to 1.75. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d5a7c77`](https://github.com/tauri-apps/plugins-workspace/commit/d5a7c77a8d0e7912a6b07b22ed329004edd6e80b)([#545](https://github.com/tauri-apps/plugins-workspace/pull/545)) Fixes docs.rs build by enabling the `tauri/dox` feature flag. +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/single-instance/Cargo.toml b/plugins/single-instance/Cargo.toml new file mode 100644 index 0000000..e636a7f --- /dev/null +++ b/plugins/single-instance/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "tauri-plugin-single-instance" +version = "2.4.5" +description = "Ensure a single instance of your tauri app is running." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +exclude = ["/banner.png", "/examples"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +tauri-plugin-deep-link = { path = "../deep-link", version = "2.4.10", optional = true } +semver = { version = "1", optional = true } +tokio = { version = "1", features = ["net"] } + +[target."cfg(target_os = \"windows\")".dependencies.windows-sys] +version = "0.60" +features = [ + "Win32_System_Threading", + "Win32_System_DataExchange", + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_Security", + "Win32_System_LibraryLoader", + "Win32_Graphics_Gdi", +] + +[target."cfg(target_os = \"linux\")".dependencies] +zbus = { workspace = true } + +[features] +semver = ["dep:semver"] +deep-link = ["dep:tauri-plugin-deep-link"] diff --git a/plugins/single-instance/LICENSE.spdx b/plugins/single-instance/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/single-instance/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/single-instance/LICENSE_APACHE-2.0 b/plugins/single-instance/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/single-instance/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/single-instance/LICENSE_MIT b/plugins/single-instance/LICENSE_MIT new file mode 100644 index 0000000..0840164 --- /dev/null +++ b/plugins/single-instance/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present The Tauri Programme in the Commons Conservancy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/single-instance/README.md b/plugins/single-instance/README.md new file mode 100644 index 0000000..e5f1fd8 --- /dev/null +++ b/plugins/single-instance/README.md @@ -0,0 +1,90 @@ +![tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/single-instance/banner.png) + +Ensure a single instance of your tauri app is running. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | x | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-single-instance = "2.0.0" +# alternatively with Git: +tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +use tauri::{Manager}; + +#[derive(Clone, serde::Serialize)] +struct Payload { + args: Vec, + cwd: String, +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { + println!("{}, {argv:?}, {cwd}", app.package_info().name); + app.emit("single-instance", Payload { args: argv, cwd }).unwrap(); + })) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Note that currently, plugins run in the order they were added in to the builder, so make sure that this plugin is registered first. + +## Usage with Flatpak/Snap + +If you use Flatpak/Snap to publish your package and your Tauri identifier doesn't match the package id, set the `DBUS_ID` variable using the builder for the plugin, look at example. + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/single-instance/SECURITY.md b/plugins/single-instance/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/single-instance/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/single-instance/banner.png b/plugins/single-instance/banner.png new file mode 100644 index 0000000..8e3cc5a Binary files /dev/null and b/plugins/single-instance/banner.png differ diff --git a/plugins/single-instance/examples/vanilla/.gitignore b/plugins/single-instance/examples/vanilla/.gitignore new file mode 100644 index 0000000..b3f41c3 --- /dev/null +++ b/plugins/single-instance/examples/vanilla/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/** +!dist/.gitkeep \ No newline at end of file diff --git a/plugins/single-instance/examples/vanilla/package.json b/plugins/single-instance/examples/vanilla/package.json new file mode 100644 index 0000000..d92ff0d --- /dev/null +++ b/plugins/single-instance/examples/vanilla/package.json @@ -0,0 +1,14 @@ +{ + "name": "app", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "tauri": "tauri" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } +} diff --git a/plugins/single-instance/examples/vanilla/public/index.html b/plugins/single-instance/examples/vanilla/public/index.html new file mode 100644 index 0000000..9c95f40 --- /dev/null +++ b/plugins/single-instance/examples/vanilla/public/index.html @@ -0,0 +1,5 @@ + + +
Plugin example
+ + diff --git a/plugins/single-instance/examples/vanilla/src-tauri/.gitignore b/plugins/single-instance/examples/vanilla/src-tauri/.gitignore new file mode 100644 index 0000000..949785a --- /dev/null +++ b/plugins/single-instance/examples/vanilla/src-tauri/.gitignore @@ -0,0 +1,6 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +WixTools + +/gen/schemas diff --git a/plugins/single-instance/examples/vanilla/src-tauri/Cargo.toml b/plugins/single-instance/examples/vanilla/src-tauri/Cargo.toml new file mode 100644 index 0000000..c98659a --- /dev/null +++ b/plugins/single-instance/examples/vanilla/src-tauri/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "single-instance-example" +version = "0.1.0" +description = "A Tauri App" +authors = ["You"] +repository = "" +edition = "2021" +rust-version = "1.77.2" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true, features = ["wry", "common-controls-v6", "x11"] } +tauri-plugin-single-instance = { path = "../../../" } +tauri-plugin-cli = { path = "../../../../cli" } + +[build-dependencies] +tauri-build = { workspace = true } diff --git a/plugins/single-instance/examples/vanilla/src-tauri/build.rs b/plugins/single-instance/examples/vanilla/src-tauri/build.rs new file mode 100644 index 0000000..5ebf8d2 --- /dev/null +++ b/plugins/single-instance/examples/vanilla/src-tauri/build.rs @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +fn main() { + tauri_build::build() +} diff --git a/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128.png b/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128.png differ diff --git a/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128@2x.png b/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/plugins/single-instance/examples/vanilla/src-tauri/icons/128x128@2x.png differ diff --git a/plugins/single-instance/examples/vanilla/src-tauri/icons/32x32.png b/plugins/single-instance/examples/vanilla/src-tauri/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/plugins/single-instance/examples/vanilla/src-tauri/icons/32x32.png differ diff --git a/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.ico b/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.ico new file mode 100644 index 0000000..b3636e4 Binary files /dev/null and b/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.ico differ diff --git a/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.png b/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/plugins/single-instance/examples/vanilla/src-tauri/icons/icon.png differ diff --git a/plugins/single-instance/examples/vanilla/src-tauri/src/main.rs b/plugins/single-instance/examples/vanilla/src-tauri/src/main.rs new file mode 100644 index 0000000..835857c --- /dev/null +++ b/plugins/single-instance/examples/vanilla/src-tauri/src/main.rs @@ -0,0 +1,22 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +fn main() { + tauri::Builder::default() + .plugin( + tauri_plugin_single_instance::Builder::new() + .callback(move |app, argv, cwd| { + println!("{}, {argv:?}, {cwd}", app.package_info().name); + }) + .dbus_id("org.Tauri.SIExampleApp".to_owned()) + .build(), + ) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json b/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json new file mode 100644 index 0000000..41623cc --- /dev/null +++ b/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json @@ -0,0 +1,45 @@ +{ + "productName": "app", + "version": "0.1.0", + "identifier": "com.tauri.single-instance", + "build": { + "frontendDist": "../public" + }, + "app": { + "windows": [ + { + "title": "app", + "width": 800, + "height": 600, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: http://tauri.localhost 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": { + "cli": { + "description": "Testing single-instance on MacOS", + "args": [ + { + "name": "somearg", + "index": 1, + "takesValue": true + } + ] + } + } +} diff --git a/plugins/single-instance/src/lib.rs b/plugins/single-instance/src/lib.rs new file mode 100644 index 0000000..9a254a5 --- /dev/null +++ b/plugins/single-instance/src/lib.rs @@ -0,0 +1,134 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Ensure a single instance of your tauri app is running. +//! +//! ## Cargo features +//! +//! - **semver**: Allows the app with SemVer incompatible versions to run alongside each other. +//! - **deep-link**: Trigger [`tauri-plugin-deep-link`](https://crates.io/crates/tauri-plugin-deep-link) event before invoking the single-instance callback. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] +#![cfg(not(any(target_os = "android", target_os = "ios")))] + +use tauri::{plugin::TauriPlugin, AppHandle, Manager, Runtime}; + +#[cfg(target_os = "windows")] +#[path = "platform_impl/windows.rs"] +mod platform_impl; +#[cfg(target_os = "linux")] +#[path = "platform_impl/linux.rs"] +mod platform_impl; +#[cfg(target_os = "macos")] +#[path = "platform_impl/macos.rs"] +mod platform_impl; + +#[cfg(feature = "semver")] +mod semver_compat; + +pub(crate) type SingleInstanceCallback = + dyn FnMut(&AppHandle, Vec, String) + Send + Sync + 'static; + +/// Initializes the plugin, calling `f` whenever a second instance of the app is started. +/// +/// This is a shortcut for [`Builder::new`] with [`Builder::callback`] set to `f`, then +/// [`Builder::build`]. Use [`Builder`] directly if you need to set a custom [`Builder::dbus_id`]. +/// +/// `f` is called with the app handle, the second instance's command line arguments +/// (as collected by [`std::env::args`], so the first element is the executable path) and its +/// current working directory. If the `deep-link` feature is enabled, the arguments are first +/// forwarded to [`tauri-plugin-deep-link`](https://crates.io/crates/tauri-plugin-deep-link) +/// before `f` runs. +/// +/// The second instance never reaches [`tauri::Builder::run`]: it hands its arguments and working +/// directory off to the first instance and exits immediately. +pub fn init, Vec, String) + Send + Sync + 'static>( + f: F, +) -> TauriPlugin { + Builder::new().callback(f).build() +} + +/// Releases the resources this plugin uses to detect other instances (the named mutex on +/// Windows, the D-Bus name on Linux or the Unix socket on macOS). +/// +/// The plugin calls this automatically on [`tauri::RunEvent::Exit`], so you normally don't need +/// to call it yourself. Call it manually before terminating the process through means that skip +/// that event, such as [`std::process::exit`], so a future instance of the app isn't mistaken +/// for a still-running one. +pub fn destroy>(manager: &M) { + platform_impl::destroy(manager) +} + +/// Builds the single-instance plugin. +/// +/// Created with [`Builder::new`] and consumed by [`Builder::build`]. +pub struct Builder { + callback: Box>, + dbus_id: Option, +} + +impl Default for Builder { + fn default() -> Self { + Self { + callback: Box::new(move |_app, _args, _| { + #[cfg(feature = "deep-link")] + if let Some(deep_link) = _app.try_state::>() { + deep_link.handle_cli_arguments(_args.iter()); + } + }), + dbus_id: None, + } + } +} + +impl Builder { + /// Creates a new builder with a no-op callback (or, when the `deep-link` feature is enabled, + /// a callback that only forwards the arguments to the deep-link plugin) and no custom D-Bus + /// ID. Use [`Builder::callback`] and [`Builder::dbus_id`] to configure it, then + /// [`Builder::build`] to create the plugin. + pub fn new() -> Self { + Default::default() + } + + /// Function to call when a secondary instance was opened by the user and killed by the plugin. + /// If the `deep-link` feature is enabled, the plugin triggers the deep-link plugin before executing the callback. + pub fn callback, Vec, String) + Send + Sync + 'static>( + mut self, + mut f: F, + ) -> Self { + self.callback = Box::new(move |app, args, cwd| { + #[cfg(feature = "deep-link")] + if let Some(deep_link) = app.try_state::>() { + deep_link.handle_cli_arguments(args.iter()); + } + f(app, args, cwd) + }); + self + } + + /// Set a custom D-Bus ID, used on Linux. The plugin will append a `.SingleInstance` subname. + /// For example `com.mycompany.myapp` will result in the plugin registering its D-Bus service on `com.mycompany.myapp.SingleInstance`. + /// Usually you want the same base ID across all components in your app. + /// + /// Defaults to the app's bundle identifier set in tauri.conf.json. + pub fn dbus_id(mut self, dbus_id: impl Into) -> Self { + self.dbus_id = Some(dbus_id.into()); + self + } + + /// Builds the single-instance [`TauriPlugin`]. + /// + /// Register it first among your app's plugins, since the plugins run in the order they were + /// added and a second instance must be detected before the rest of your setup runs. + pub fn build(self) -> TauriPlugin { + platform_impl::init( + self.callback, + #[cfg(target_os = "linux")] + self.dbus_id, + ) + } +} diff --git a/plugins/single-instance/src/platform_impl/linux.rs b/plugins/single-instance/src/platform_impl/linux.rs new file mode 100644 index 0000000..892baee --- /dev/null +++ b/plugins/single-instance/src/platform_impl/linux.rs @@ -0,0 +1,112 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[cfg(feature = "semver")] +use crate::semver_compat::semver_compat_string; + +use crate::SingleInstanceCallback; +use tauri::{ + plugin::{self, TauriPlugin}, + AppHandle, Manager, RunEvent, Runtime, +}; +use zbus::{blocking::Connection, interface, names::WellKnownName}; + +struct ConnectionHandle(Connection); + +struct SingleInstanceDBus { + callback: Box>, + app_handle: AppHandle, +} + +#[interface(name = "org.SingleInstance.DBus")] +impl SingleInstanceDBus { + fn execute_callback(&mut self, argv: Vec, cwd: String) { + (self.callback)(&self.app_handle, argv, cwd); + } +} + +struct DBusName(String); + +pub fn init( + callback: Box>, + dbus_id: Option, +) -> TauriPlugin { + plugin::Builder::new("single-instance") + .setup(move |app, _api| { + let mut dbus_name = dbus_id.unwrap_or_else(|| app.config().identifier.clone()); + dbus_name.push_str(".SingleInstance"); + + #[cfg(feature = "semver")] + { + dbus_name.push('_'); + dbus_name.push_str(semver_compat_string(&app.package_info().version).as_str()); + } + + let mut dbus_path = dbus_name.replace('.', "/").replace('-', "_"); + if !dbus_path.starts_with('/') { + dbus_path = format!("/{dbus_path}"); + } + + let single_instance_dbus = SingleInstanceDBus { + callback, + app_handle: app.clone(), + }; + + match zbus::blocking::connection::Builder::session() + .unwrap() + .name(dbus_name.as_str()) + .unwrap() + .replace_existing_names(false) + .allow_name_replacements(false) + .serve_at(dbus_path.as_str(), single_instance_dbus) + .unwrap() + .build() + { + Ok(connection) => { + app.manage(ConnectionHandle(connection)); + } + Err(zbus::Error::NameTaken) => { + if let Ok(connection) = Connection::session() { + let _ = connection.call_method( + Some(dbus_name.as_str()), + dbus_path.as_str(), + Some("org.SingleInstance.DBus"), + "ExecuteCallback", + &( + std::env::args().collect::>(), + std::env::current_dir() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ), + ); + } + app.cleanup_before_exit(); + std::process::exit(0); + } + _ => {} + } + + app.manage(DBusName(dbus_name)); + + Ok(()) + }) + .on_event(move |app, event| { + if let RunEvent::Exit = event { + destroy(app); + } + }) + .build() +} + +pub fn destroy>(manager: &M) { + if let Some(connection) = manager.try_state::() { + if let Some(dbus_name) = manager + .try_state::() + .and_then(|name| WellKnownName::try_from(name.0.clone()).ok()) + { + let _ = connection.0.release_name(dbus_name); + } + } +} diff --git a/plugins/single-instance/src/platform_impl/macos.rs b/plugins/single-instance/src/platform_impl/macos.rs new file mode 100644 index 0000000..bd2c74a --- /dev/null +++ b/plugins/single-instance/src/platform_impl/macos.rs @@ -0,0 +1,132 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + io::{BufWriter, Error, ErrorKind, Write}, + os::unix::net::UnixStream, + path::PathBuf, +}; + +#[cfg(feature = "semver")] +use crate::semver_compat::semver_compat_string; +use crate::SingleInstanceCallback; +use tauri::{ + plugin::{self, TauriPlugin}, + AppHandle, Config, Manager, RunEvent, Runtime, +}; +use tokio::io::AsyncReadExt; + +pub fn init(cb: Box>) -> TauriPlugin { + plugin::Builder::new("single-instance") + .setup(|app, _api| { + let socket = socket_path(app.config(), app.package_info()); + + // Notify the singleton which may or may not exist. + match notify_singleton(&socket) { + Ok(_) => { + std::process::exit(0); + } + Err(e) => { + match e.kind() { + ErrorKind::NotFound | ErrorKind::ConnectionRefused => { + // This process claims itself as singleton as likely none exists + socket_cleanup(&socket); + listen_for_other_instances(socket, app.clone(), cb); + } + _ => { + tracing::debug!( + "single_instance failed to notify - launching normally: {}", + e + ); + } + } + } + } + Ok(()) + }) + .on_event(|app, event| { + if let RunEvent::Exit = event { + destroy(app); + } + }) + .build() +} + +pub fn destroy>(manager: &M) { + let socket = socket_path(manager.config(), manager.package_info()); + socket_cleanup(&socket); +} + +fn socket_path(config: &Config, _package_info: &tauri::PackageInfo) -> PathBuf { + let identifier = config.identifier.replace(['.', '-'].as_ref(), "_"); + + #[cfg(feature = "semver")] + let identifier = format!( + "{identifier}_{}", + semver_compat_string(&_package_info.version), + ); + + // Use /tmp as socket path must be shorter than 100 chars. + PathBuf::from(format!("/tmp/{}_si.sock", identifier)) +} + +fn socket_cleanup(socket: &PathBuf) { + let _ = std::fs::remove_file(socket); +} + +fn notify_singleton(socket: &PathBuf) -> Result<(), Error> { + let stream = UnixStream::connect(socket)?; + let mut bf = BufWriter::new(&stream); + let cwd = std::env::current_dir() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_string(); + bf.write_all(cwd.as_bytes())?; + bf.write_all(b"\0\0")?; + let args_joined = std::env::args().collect::>().join("\0"); + bf.write_all(args_joined.as_bytes())?; + bf.flush()?; + drop(bf); + Ok(()) +} + +fn listen_for_other_instances( + socket: PathBuf, + app: AppHandle
, + mut cb: Box>, +) { + tauri::async_runtime::spawn(async move { + match tokio::net::UnixListener::bind(socket) { + Ok(listener) => loop { + match listener.accept().await { + Ok((mut stream, _addr)) => { + let mut s = String::new(); + match stream.read_to_string(&mut s).await { + Ok(_) => { + let (cwd, args) = s.split_once("\0\0").unwrap_or_default(); + let args: Vec = + args.split('\0').map(String::from).collect(); + cb(app.app_handle(), args, cwd.to_string()); + } + Err(e) => { + tracing::debug!("single_instance failed to be notified: {e}") + } + } + } + Err(err) => { + tracing::debug!("single_instance failed to be notified: {}", err); + continue; + } + } + }, + Err(err) => { + tracing::error!( + "single_instance failed to listen to other processes - launching normally: {}", + err + ); + } + } + }); +} diff --git a/plugins/single-instance/src/platform_impl/windows.rs b/plugins/single-instance/src/platform_impl/windows.rs new file mode 100644 index 0000000..196f785 --- /dev/null +++ b/plugins/single-instance/src/platform_impl/windows.rs @@ -0,0 +1,267 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[cfg(feature = "semver")] +use crate::semver_compat::semver_compat_string; + +use crate::SingleInstanceCallback; +use std::ffi::CStr; +use tauri::{ + plugin::{self, TauriPlugin}, + AppHandle, Manager, RunEvent, Runtime, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM}, + System::{ + DataExchange::COPYDATASTRUCT, + LibraryLoader::GetModuleHandleW, + Threading::{CreateMutexW, ReleaseMutex}, + }, + UI::WindowsAndMessaging::{ + self as w32wm, AllowSetForegroundWindow, CreateWindowExW, DefWindowProcW, DestroyWindow, + FindWindowW, GetWindowThreadProcessId, RegisterClassExW, SendMessageW, CREATESTRUCTW, + GWLP_USERDATA, GWL_STYLE, WINDOW_LONG_PTR_INDEX, WM_COPYDATA, WM_CREATE, WM_DESTROY, + WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, + WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, + }, +}; + +const WMCOPYDATA_SINGLE_INSTANCE_DATA: usize = 1542; + +struct MutexHandle(isize); + +struct TargetWindowHandle(isize); + +struct UserData { + app: AppHandle, + callback: Box>, +} + +impl UserData { + unsafe fn from_hwnd_raw(hwnd: HWND) -> *mut Self { + GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut Self + } + + unsafe fn from_hwnd<'a>(hwnd: HWND) -> &'a mut Self { + &mut *Self::from_hwnd_raw(hwnd) + } + + fn run_callback(&mut self, args: Vec, cwd: String) { + (self.callback)(&self.app, args, cwd) + } +} + +pub fn init(callback: Box>) -> TauriPlugin { + plugin::Builder::new("single-instance") + .setup(|app, _api| { + #[allow(unused_mut)] + let mut id = app.config().identifier.clone(); + #[cfg(feature = "semver")] + { + id.push('_'); + id.push_str(semver_compat_string(&app.package_info().version).as_str()); + } + + let class_name = encode_wide(format!("{id}-sic")); + let window_name = encode_wide(format!("{id}-siw")); + let mutex_name = encode_wide(format!("{id}-sim")); + + let hmutex = + unsafe { CreateMutexW(std::ptr::null(), true.into(), mutex_name.as_ptr()) }; + + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { + let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr()); + + if !hwnd.is_null() { + // Windows lets us bring a window to the front, but not the first + // instance. Hand that right over before we exit, so focusing a window + // from the callback works. Windows takes it back if the user switches + // to another app in the meantime. + let mut pid = 0; + GetWindowThreadProcessId(hwnd, &mut pid); + if pid != 0 { + AllowSetForegroundWindow(pid); + } + + let cwd = std::env::current_dir().unwrap_or_default(); + let cwd = cwd.to_str().unwrap_or_default(); + + let args = std::env::args().collect::>().join("|"); + + let data = format!("{cwd}|{args}\0",); + + let bytes = data.as_bytes(); + let cds = COPYDATASTRUCT { + dwData: WMCOPYDATA_SINGLE_INSTANCE_DATA, + cbData: bytes.len() as _, + lpData: bytes.as_ptr() as _, + }; + + SendMessageW(hwnd, WM_COPYDATA, 0, &cds as *const _ as _); + + app.cleanup_before_exit(); + std::process::exit(0); + } + } + } else { + app.manage(MutexHandle(hmutex as _)); + + let userdata = UserData { + app: app.clone(), + callback, + }; + let userdata = Box::into_raw(Box::new(userdata)); + let hwnd = create_event_target_window::(&class_name, &window_name, userdata); + app.manage(TargetWindowHandle(hwnd as _)); + } + + Ok(()) + }) + .on_event(|app, event| { + if let RunEvent::Exit = event { + destroy(app); + } + }) + .build() +} + +pub fn destroy>(manager: &M) { + if let Some(hmutex) = manager.try_state::() { + unsafe { + ReleaseMutex(hmutex.0 as _); + CloseHandle(hmutex.0 as _); + } + } + if let Some(hwnd) = manager.try_state::() { + unsafe { DestroyWindow(hwnd.0 as _) }; + } +} + +unsafe extern "system" fn single_instance_window_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + let create_struct = &*(lparam as *const CREATESTRUCTW); + let userdata = create_struct.lpCreateParams as *const UserData; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, userdata as _); + 0 + } + + WM_COPYDATA => { + let cds_ptr = lparam as *const COPYDATASTRUCT; + if (*cds_ptr).dwData == WMCOPYDATA_SINGLE_INSTANCE_DATA { + let userdata = UserData::::from_hwnd(hwnd); + + let data = CStr::from_ptr((*cds_ptr).lpData as _).to_string_lossy(); + let mut s = data.split('|'); + let cwd = s.next().unwrap(); + let args = s.map(|s| s.to_string()).collect(); + + userdata.run_callback(args, cwd.to_string()); + } + 1 + } + + WM_DESTROY => { + let userdata = UserData::::from_hwnd_raw(hwnd); + drop(Box::from_raw(userdata)); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +fn create_event_target_window( + class_name: &[u16], + window_name: &[u16], + userdata: *const UserData, +) -> HWND { + unsafe { + let class = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: Some(single_instance_window_proc::), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: GetModuleHandleW(std::ptr::null()), + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + + RegisterClassExW(&class); + + let hwnd = CreateWindowExW( + WS_EX_NOACTIVATE + | WS_EX_TRANSPARENT + | WS_EX_LAYERED + // WS_EX_TOOLWINDOW prevents this window from ever showing up in the taskbar, which + // we want to avoid. If you remove this style, this window won't show up in the + // taskbar *initially*, but it can show up at some later point. This can sometimes + // happen on its own after several hours have passed, although this has proven + // difficult to reproduce. Alternatively, it can be manually triggered by killing + // `explorer.exe` and then starting the process back up. + // It is unclear why the bug is triggered by waiting for several hours. + | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + window_name.as_ptr(), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + userdata as _, + ); + SetWindowLongPtrW( + hwnd, + GWL_STYLE, + // The window technically has to be visible to receive WM_PAINT messages (which are used + // for delivering events during resizes), but it isn't displayed to the user because of + // the LAYERED style. + (WS_VISIBLE | WS_POPUP) as isize, + ); + hwnd + } +} + +pub fn encode_wide(string: impl AsRef) -> Vec { + std::os::windows::prelude::OsStrExt::encode_wide(string.as_ref()) + .chain(std::iter::once(0)) + .collect() +} + +#[cfg(target_pointer_width = "32")] +#[allow(non_snake_case)] +unsafe fn SetWindowLongPtrW(hwnd: HWND, index: WINDOW_LONG_PTR_INDEX, value: isize) -> isize { + w32wm::SetWindowLongW(hwnd, index, value as _) as _ +} + +#[cfg(target_pointer_width = "64")] +#[allow(non_snake_case)] +unsafe fn SetWindowLongPtrW(hwnd: HWND, index: WINDOW_LONG_PTR_INDEX, value: isize) -> isize { + w32wm::SetWindowLongPtrW(hwnd, index, value) +} + +#[cfg(target_pointer_width = "32")] +#[allow(non_snake_case)] +unsafe fn GetWindowLongPtrW(hwnd: HWND, index: WINDOW_LONG_PTR_INDEX) -> isize { + w32wm::GetWindowLongW(hwnd, index) as _ +} + +#[cfg(target_pointer_width = "64")] +#[allow(non_snake_case)] +unsafe fn GetWindowLongPtrW(hwnd: HWND, index: WINDOW_LONG_PTR_INDEX) -> isize { + w32wm::GetWindowLongPtrW(hwnd, index) +} diff --git a/plugins/single-instance/src/semver_compat.rs b/plugins/single-instance/src/semver_compat.rs new file mode 100644 index 0000000..2920f1b --- /dev/null +++ b/plugins/single-instance/src/semver_compat.rs @@ -0,0 +1,19 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/// Takes a version and spits out a String with trailing _x, thus only considering the digits +/// relevant regarding semver compatibility +pub fn semver_compat_string(version: &semver::Version) -> String { + // for pre-release always treat each version separately + if !version.pre.is_empty() { + return version.to_string().replace(['.', '-'], "_"); + } + match version.major { + 0 => match version.minor { + 0 => format!("0_0_{}", version.patch), + _ => format!("0_{}_x", version.minor), + }, + _ => format!("{}_x_x", version.major), + } +} diff --git a/plugins/sql/CHANGELOG.md b/plugins/sql/CHANGELOG.md new file mode 100644 index 0000000..35c93c5 --- /dev/null +++ b/plugins/sql/CHANGELOG.md @@ -0,0 +1,142 @@ +# Changelog + +## [2.4.1] + +- [`f8053e65`](https://github.com/tauri-apps/plugins-workspace/commit/f8053e659e4ccd85c1f52833411ff8417cbc5e69) ([#3527](https://github.com/tauri-apps/plugins-workspace/pull/3527) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Documented Cargo feature flags in each plugin's crate-level documentation. + +## \[2.4.0] + +- [`1dc36128`](https://github.com/tauri-apps/plugins-workspace/commit/1dc36128624f6954e09ea1574f68615e34e83f63) ([#3275](https://github.com/tauri-apps/plugins-workspace/pull/3275) by [@mikenikles](https://github.com/tauri-apps/plugins-workspace/../../mikenikles)) Add support for Postgres `NUMERIC` and custom data types. + +## \[2.3.2] + +- [`2dc3f3f0`](https://github.com/tauri-apps/plugins-workspace/commit/2dc3f3f0397fa91b1f3ffbc4b42ca2b4ae55bbe0) ([#3144](https://github.com/tauri-apps/plugins-workspace/pull/3144)) Fixes issue with UUIDs returned as null in sql query results. + +## \[2.3.1] + +- [`93426f85`](https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9) Fixed an issue that caused docs.rs builds to fail. No user facing changes. + +## \[2.3.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.2.1] + +- [`f634e524`](https://github.com/tauri-apps/plugins-workspace/commit/f634e5248ebe428f8305a59f74c13fc15147fb8e) This is an "empty" release to update the plugins' source files on crates.io and docs.rs. This should fix docs.rs build failures for projects using tauri plugins as dependencies. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.0.2] + +- [`31956469`](https://github.com/tauri-apps/plugins-workspace/commit/319564699638c080b73d506bcaad186ecc4a8236) ([#1928](https://github.com/tauri-apps/plugins-workspace/pull/1928) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed the QueryResult typing by marking `lastInsertId` as optional to reflect postgres-only changes made in the 2.0.0 release. + +## \[2.0.3] + +- [`90ef77c8`](https://github.com/tauri-apps/plugins-workspace/commit/90ef77c8723ac9d0ba7bd3b52a80a2b14843ff99) ([#2038](https://github.com/tauri-apps/plugins-workspace/pull/2038) by [@johncarmack1984](https://github.com/tauri-apps/plugins-workspace/../../johncarmack1984)) Allow blocking on async code without creating a nested runtime. + +## \[2.0.1] + +- [`0ca4cc91`](https://github.com/tauri-apps/plugins-workspace/commit/0ca4cc914c5ea995c98f9e60a2ab49827c219350) ([#1963](https://github.com/tauri-apps/plugins-workspace/pull/1963) by [@yoggys](https://github.com/tauri-apps/plugins-workspace/../../yoggys)) Fixed incorrect documentation of the select method in the Database class. + +## \[2.0.1] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.3] + +- [`30bcf5dc`](https://github.com/tauri-apps/plugins-workspace/commit/30bcf5dcc22e1bb1fb983a8d2887edc39404e6df) ([#1838](https://github.com/tauri-apps/plugins-workspace/pull/1838) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) It is now possible to enable multiple SQL backends at the same time. There will be no compile error anymore if no backends are enabled! + +## \[2.0.0-rc.2] + +- [`0dd97d91`](https://github.com/tauri-apps/plugins-workspace/commit/0dd97d911569cdedab07f504b708036d62ff83c1) ([#1375](https://github.com/tauri-apps/plugins-workspace/pull/1375) by [@KauanCurbani](https://github.com/tauri-apps/plugins-workspace/../../KauanCurbani)) Added support for `UUID` columns to the postgres implementation. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.10] + +- [`37cb9a66`](https://github.com/tauri-apps/plugins-workspace/commit/37cb9a6681b948908cd9443340f6b23401607df7) ([#1575](https://github.com/tauri-apps/plugins-workspace/pull/1575) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update sqlx to 0.8 - Check out their changelog for behavior changes: https://github.com/launchbadge/sqlx/blob/main/CHANGELOG.md#080---2024-07-22 + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.7] + +- [`4216c051`](https://github.com/tauri-apps/plugins-workspace/commit/4216c0517fd1dcb29d0162dc2fc15291472a2b00) ([#1381](https://github.com/tauri-apps/plugins-workspace/pull/1381) by [@thewh1teagle](https://github.com/tauri-apps/plugins-workspace/../../thewh1teagle)) Made `DbInstances` public for managing database instances directly from `Rust`. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.3] + +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/plugins/sql/Cargo.toml b/plugins/sql/Cargo.toml new file mode 100644 index 0000000..4cb324a --- /dev/null +++ b/plugins/sql/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "tauri-plugin-sql" +version = "2.4.1" +description = "Interface with SQL databases." +authors = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-sql" +exclude = [ + "/banner.png", + "/guest-js", + "/package.json", + "/rollup.config.js", + "/tsconfig.json", +] + +[package.metadata.docs.rs] +features = ["sqlite"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "full", notes = "" } +ios = { level = "full", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +futures-core = "0.3" +sqlx = { version = "0.8", features = ["json", "time", "uuid", "rust_decimal"] } +rust_decimal = "1" +time = "0.3" +tokio = { version = "1", features = ["sync"] } +indexmap = { version = "2", features = ["serde"] } +uuid = "1" + +[features] +sqlite = ["sqlx/sqlite", "sqlx/runtime-tokio"] +mysql = ["sqlx/mysql", "sqlx/runtime-tokio-rustls"] +postgres = ["sqlx/postgres", "sqlx/runtime-tokio-rustls"] +# TODO: bundled-cipher etc diff --git a/plugins/sql/LICENSE.spdx b/plugins/sql/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/plugins/sql/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/plugins/sql/LICENSE_APACHE-2.0 b/plugins/sql/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/plugins/sql/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/plugins/sql/LICENSE_MIT b/plugins/sql/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/plugins/sql/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/sql/README.md b/plugins/sql/README.md new file mode 100644 index 0000000..2e1e4ca --- /dev/null +++ b/plugins/sql/README.md @@ -0,0 +1,211 @@ +![plugin-sql](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/sql/banner.png) + +Interface with SQL databases through [sqlx](https://github.com/launchbadge/sqlx). It supports the `sqlite`, `mysql` and `postgres` drivers, enabled by a Cargo feature. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | ✓ | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +[dependencies.tauri-plugin-sql] +features = ["sqlite"] # or "postgres", or "mysql" +version = "2.0.0" +# alternatively with Git +git = "https://github.com/tauri-apps/plugins-workspace" +branch = "v2" +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-sql +# or +npm add @tauri-apps/plugin-sql +# or +yarn add @tauri-apps/plugin-sql +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_sql::Builder::default().build()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import Database from '@tauri-apps/plugin-sql' + +// sqlite. The path is relative to `tauri::api::path::BaseDirectory::AppConfig`. +const db = await Database.load('sqlite:test.db') +// mysql +const db = await Database.load('mysql://user:pass@host/database') +// postgres +const db = await Database.load('postgres://postgres:password@localhost/test') + +await db.execute('INSERT INTO ...') +``` + +## Syntax + +We use sqlx as our underlying library, adopting their query syntax: + +- sqlite and postgres use the "$#" syntax when substituting query data +- mysql uses "?" when substituting query data + +```javascript +// INSERT and UPDATE examples for sqlite and postgres +const result = await db.execute( + 'INSERT into todos (id, title, status) VALUES ($1, $2, $3)', + [todos.id, todos.title, todos.status] +) + +const result = await db.execute( + 'UPDATE todos SET title = $1, status = $2 WHERE id = $3', + [todos.title, todos.status, todos.id] +) + +// INSERT and UPDATE examples for mysql +const result = await db.execute( + 'INSERT into todos (id, title, status) VALUES (?, ?, ?)', + [todos.id, todos.title, todos.status] +) + +const result = await db.execute( + 'UPDATE todos SET title = ?, status = ? WHERE id = ?', + [todos.title, todos.status, todos.id] +) +``` + +## Migrations + +This plugin supports database migrations, allowing you to manage database schema evolution over time. + +### Defining Migrations + +Migrations are defined in Rust using the `Migration` struct. Each migration should include a unique version number, a description, the SQL to be executed, and the type of migration (Up or Down). + +Example of a migration: + +```rust +use tauri_plugin_sql::{Migration, MigrationKind}; + +let migration = Migration { + version: 1, + description: "create_initial_tables", + sql: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", + kind: MigrationKind::Up, +}; +``` + +### Adding Migrations to the Plugin Builder + +Migrations are registered with the `Builder` struct provided by the plugin. Use the `add_migrations` method to add your migrations to the plugin for a specific database connection. + +Example of adding migrations: + +```rust +use tauri_plugin_sql::{Builder, Migration, MigrationKind}; + +fn main() { + let migrations = vec![ + // Define your migrations here + Migration { + version: 1, + description: "create_initial_tables", + sql: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", + kind: MigrationKind::Up, + } + ]; + + tauri::Builder::default() + .plugin( + tauri_plugin_sql::Builder::default() + .add_migrations("sqlite:mydatabase.db", migrations) + .build(), + ) + ... +} +``` + +### Applying Migrations + +To apply the migrations when the plugin is initialized, add the connection string to the `tauri.conf.json` file: + +```json +{ + "plugins": { + "sql": { + "preload": ["sqlite:mydatabase.db"] + } + } +} +``` + +Alternatively, the client side `load()` also runs the migrations for a given connection string: + +```ts +import Database from '@tauri-apps/plugin-sql' +const db = await Database.load('sqlite:mydatabase.db') +``` + +Ensure that the migrations are defined in the correct order and are safe to run multiple times. + +### Migration Management + +- **Version Control**: Each migration must have a unique version number. This is crucial for ensuring the migrations are applied in the correct order. +- **Idempotency**: Write migrations in a way that they can be safely re-run without causing errors or unintended consequences. +- **Testing**: Thoroughly test migrations to ensure they work as expected and do not compromise the integrity of your database. + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/plugins/sql/SECURITY.md b/plugins/sql/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/plugins/sql/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/plugins/sql/api-iife.js b/plugins/sql/api-iife.js new file mode 100644 index 0000000..036d8ee --- /dev/null +++ b/plugins/sql/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_SQL__=function(e){"use strict";class t{constructor(e){this.path=e}static async load(n){const s=await e.invoke("plugin:sql|load",{db:n});return new t(s)}static get(e){return new t(e)}async execute(t,n){const[s,a]=await e.invoke("plugin:sql|execute",{db:this.path,query:t,values:n??[]});return{lastInsertId:a,rowsAffected:s}}async select(t,n){return await e.invoke("plugin:sql|select",{db:this.path,query:t,values:n??[]})}async close(t){return await e.invoke("plugin:sql|close",{db:t})}}return t}(window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"sql",{value:__TAURI_PLUGIN_SQL__})} diff --git a/plugins/sql/banner.png b/plugins/sql/banner.png new file mode 100644 index 0000000..5e53a31 Binary files /dev/null and b/plugins/sql/banner.png differ diff --git a/plugins/sql/build.rs b/plugins/sql/build.rs new file mode 100644 index 0000000..fbbca42 --- /dev/null +++ b/plugins/sql/build.rs @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["load", "execute", "select", "close"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); +} diff --git a/plugins/sql/guest-js/index.ts b/plugins/sql/guest-js/index.ts new file mode 100644 index 0000000..19e40af --- /dev/null +++ b/plugins/sql/guest-js/index.ts @@ -0,0 +1,226 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Interface with SQL databases through [sqlx](https://github.com/launchbadge/sqlx). + * Which database engines can be used depends on the drivers enabled on the Rust + * side of the plugin: SQLite, MySQL and PostgreSQL. + * + * @module + */ + +import { invoke } from '@tauri-apps/api/core' + +/** + * The outcome of a statement run through {@link Database.execute}. + */ +export interface QueryResult { + /** The number of rows affected by the query. */ + rowsAffected: number + /** + * The last inserted `id`. + * + * This value is not set for Postgres databases. If the + * last inserted id is required on Postgres, the `select` function + * must be used, with a `RETURNING` clause + * (`INSERT INTO todos (title) VALUES ($1) RETURNING id`). + */ + lastInsertId?: number +} + +/** + * **Database** + * + * The `Database` class serves as the primary interface for + * communicating with the rust side of the sql plugin. + * + * @since 2.0.0 + */ +export default class Database { + /** + * The connection string identifying the database on the Rust side, + * for instance `sqlite:test.db`, `mysql://user:pass@host/database` + * or `postgres://user:pass@host/database`. + */ + path: string + + /** + * Creates a `Database` instance for the given connection string without + * opening a connection to it. Use {@link Database.load} to connect to the + * database, or {@link Database.get} for a database that is already loaded. + * + * @param path The database connection string, such as `sqlite:test.db`. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = new Database('sqlite:test.db') + * ``` + */ + constructor(path: string) { + this.path = path + } + + /** + * **load** + * + * A static initializer which connects to the underlying database and + * returns a `Database` instance once a connection to the database is established. + * + * # Sqlite + * + * The path is relative to `tauri::path::BaseDirectory::App` and must start with `sqlite:`. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * ``` + * + * @param path The database connection string, such as `sqlite:test.db`. The database is created if it does not exist yet, and any migration registered for it on the Rust side is run. + * @returns A promise resolving to a `Database` instance connected to the given database. + */ + static async load(path: string): Promise { + const _path = await invoke('plugin:sql|load', { + db: path + }) + + return new Database(_path) + } + + /** + * **get** + * + * A static initializer which synchronously returns an instance of + * the Database class while deferring the actual database connection + * until the first invocation or selection on the database. + * + * # Sqlite + * + * The path is relative to `tauri::path::BaseDirectory::App` and must start with `sqlite:`. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = Database.get('sqlite:test.db') + * ``` + * + * @param path The database connection string, such as `sqlite:test.db`. + * @returns A `Database` instance bound to the given connection string. + */ + static get(path: string): Database { + return new Database(path) + } + + /** + * **execute** + * + * Passes a SQL expression to the database for execution. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * + * // for sqlite & postgres + * // INSERT example + * const result = await db.execute( + * "INSERT into todos (id, title, status) VALUES ($1, $2, $3)", + * [ todos.id, todos.title, todos.status ] + * ); + * // UPDATE example + * const result = await db.execute( + * "UPDATE todos SET title = $1, completed = $2 WHERE id = $3", + * [ todos.title, todos.status, todos.id ] + * ); + * + * // for mysql + * // INSERT example + * const result = await db.execute( + * "INSERT into todos (id, title, status) VALUES (?, ?, ?)", + * [ todos.id, todos.title, todos.status ] + * ); + * // UPDATE example + * const result = await db.execute( + * "UPDATE todos SET title = ?, completed = ? WHERE id = ?", + * [ todos.title, todos.status, todos.id ] + * ); + * ``` + * + * @param query The SQL statement to run, using `$1`, `$2`, ... placeholders on SQLite and PostgreSQL and `?` placeholders on MySQL. + * @param bindValues The values bound to the query placeholders, in the order they appear in the statement. Defaults to no values. + * @returns A promise resolving to the number of rows affected by the statement and the last inserted id. + */ + async execute(query: string, bindValues?: unknown[]): Promise { + const [rowsAffected, lastInsertId] = await invoke<[number, number]>( + 'plugin:sql|execute', + { + db: this.path, + query, + values: bindValues ?? [] + } + ) + return { + lastInsertId, + rowsAffected + } + } + + /** + * **select** + * + * Passes in a SELECT query to the database for execution. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * + * // for sqlite & postgres + * const result = await db.select( + * "SELECT * from todos WHERE id = $1", [ id ] + * ); + * + * // for mysql + * const result = await db.select( + * "SELECT * from todos WHERE id = ?", [ id ] + * ); + * ``` + * + * @param query The SQL query to run, using `$1`, `$2`, ... placeholders on SQLite and PostgreSQL and `?` placeholders on MySQL. + * @param bindValues The values bound to the query placeholders, in the order they appear in the query. Defaults to no values. + * @returns A promise resolving to the selected rows, each row being an object keyed by column name. + */ + async select(query: string, bindValues?: unknown[]): Promise { + const result = await invoke('plugin:sql|select', { + db: this.path, + query, + values: bindValues ?? [] + }) + + return result + } + + /** + * **close** + * + * Closes the database connection pool. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * const success = await db.close() + * ``` + * + * @param db - Optionally state the name of a database if you are managing more than one. Otherwise, all database pools will be in scope. + * @returns A promise resolving to `true` once the matching connection pools have been closed. + */ + async close(db?: string): Promise { + const success = await invoke('plugin:sql|close', { + db + }) + return success + } +} diff --git a/plugins/sql/package.json b/plugins/sql/package.json new file mode 100644 index 0000000..b6eb93f --- /dev/null +++ b/plugins/sql/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tauri-apps/plugin-sql", + "version": "2.4.1", + "description": "Interface with SQL databases", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } +} diff --git a/plugins/sql/permissions/autogenerated/commands/close.toml b/plugins/sql/permissions/autogenerated/commands/close.toml new file mode 100644 index 0000000..fad12d1 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/close.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-close" +description = "Enables the close command without any pre-configured scope." +commands.allow = ["close"] + +[[permission]] +identifier = "deny-close" +description = "Denies the close command without any pre-configured scope." +commands.deny = ["close"] diff --git a/plugins/sql/permissions/autogenerated/commands/execute.toml b/plugins/sql/permissions/autogenerated/commands/execute.toml new file mode 100644 index 0000000..d98be89 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/execute.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-execute" +description = "Enables the execute command without any pre-configured scope." +commands.allow = ["execute"] + +[[permission]] +identifier = "deny-execute" +description = "Denies the execute command without any pre-configured scope." +commands.deny = ["execute"] diff --git a/plugins/sql/permissions/autogenerated/commands/load.toml b/plugins/sql/permissions/autogenerated/commands/load.toml new file mode 100644 index 0000000..f6e47ad --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/load.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-load" +description = "Enables the load command without any pre-configured scope." +commands.allow = ["load"] + +[[permission]] +identifier = "deny-load" +description = "Denies the load command without any pre-configured scope." +commands.deny = ["load"] diff --git a/plugins/sql/permissions/autogenerated/commands/select.toml b/plugins/sql/permissions/autogenerated/commands/select.toml new file mode 100644 index 0000000..5a13a02 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/select.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-select" +description = "Enables the select command without any pre-configured scope." +commands.allow = ["select"] + +[[permission]] +identifier = "deny-select" +description = "Denies the select command without any pre-configured scope." +commands.deny = ["select"] diff --git a/plugins/sql/permissions/autogenerated/reference.md b/plugins/sql/permissions/autogenerated/reference.md new file mode 100644 index 0000000..c829b10 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/reference.md @@ -0,0 +1,131 @@ +## Default Permission + +### Default Permissions + +This permission set configures what kind of +database operations are available from the sql plugin. + +### Granted Permissions + +All reading related operations are enabled. +Also allows to load or close a connection. + +#### This default permission set includes the following: + +- `allow-close` +- `allow-load` +- `allow-select` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`sql:allow-close` + + + +Enables the close command without any pre-configured scope. + +
+ +`sql:deny-close` + + + +Denies the close command without any pre-configured scope. + +
+ +`sql:allow-execute` + + + +Enables the execute command without any pre-configured scope. + +
+ +`sql:deny-execute` + + + +Denies the execute command without any pre-configured scope. + +
+ +`sql:allow-load` + + + +Enables the load command without any pre-configured scope. + +
+ +`sql:deny-load` + + + +Denies the load command without any pre-configured scope. + +
+ +`sql:allow-select` + + + +Enables the select command without any pre-configured scope. + +
+ +`sql:deny-select` + + + +Denies the select command without any pre-configured scope. + +
diff --git a/plugins/sql/permissions/default.toml b/plugins/sql/permissions/default.toml new file mode 100644 index 0000000..eb5fa55 --- /dev/null +++ b/plugins/sql/permissions/default.toml @@ -0,0 +1,16 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +### Default Permissions + +This permission set configures what kind of +database operations are available from the sql plugin. + +### Granted Permissions + +All reading related operations are enabled. +Also allows to load or close a connection. + +""" +permissions = ["allow-close", "allow-load", "allow-select"] diff --git a/plugins/sql/permissions/schemas/schema.json b/plugins/sql/permissions/schemas/schema.json new file mode 100644 index 0000000..488a953 --- /dev/null +++ b/plugins/sql/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Enables the load command without any pre-configured scope.", + "type": "string", + "const": "allow-load", + "markdownDescription": "Enables the load command without any pre-configured scope." + }, + { + "description": "Denies the load command without any pre-configured scope.", + "type": "string", + "const": "deny-load", + "markdownDescription": "Denies the load command without any pre-configured scope." + }, + { + "description": "Enables the select command without any pre-configured scope.", + "type": "string", + "const": "allow-select", + "markdownDescription": "Enables the select command without any pre-configured scope." + }, + { + "description": "Denies the select command without any pre-configured scope.", + "type": "string", + "const": "deny-select", + "markdownDescription": "Denies the select command without any pre-configured scope." + }, + { + "description": "### Default Permissions\n\nThis permission set configures what kind of\ndatabase operations are available from the sql plugin.\n\n### Granted Permissions\n\nAll reading related operations are enabled.\nAlso allows to load or close a connection.\n\n\n#### This default permission set includes:\n\n- `allow-close`\n- `allow-load`\n- `allow-select`", + "type": "string", + "const": "default", + "markdownDescription": "### Default Permissions\n\nThis permission set configures what kind of\ndatabase operations are available from the sql plugin.\n\n### Granted Permissions\n\nAll reading related operations are enabled.\nAlso allows to load or close a connection.\n\n\n#### This default permission set includes:\n\n- `allow-close`\n- `allow-load`\n- `allow-select`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/sql/rollup.config.js b/plugins/sql/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/plugins/sql/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/plugins/sql/src/commands.rs b/plugins/sql/src/commands.rs new file mode 100644 index 0000000..760d00b --- /dev/null +++ b/plugins/sql/src/commands.rs @@ -0,0 +1,80 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use indexmap::IndexMap; +use serde_json::Value as JsonValue; +use sqlx::migrate::Migrator; +use tauri::{command, AppHandle, Runtime, State}; + +use crate::{DbInstances, DbPool, Error, LastInsertId, Migrations}; + +#[command] +pub(crate) async fn load( + app: AppHandle, + db_instances: State<'_, DbInstances>, + migrations: State<'_, Migrations>, + db: String, +) -> Result { + let pool = DbPool::connect(&db, &app).await?; + + if let Some(migrations) = migrations.0.lock().await.remove(&db) { + let migrator = Migrator::new(migrations).await?; + pool.migrate(&migrator).await?; + } + + db_instances.0.write().await.insert(db.clone(), pool); + + Ok(db) +} + +/// Allows the database connection(s) to be closed; if no database +/// name is passed in then _all_ database connection pools will be +/// shut down. +#[command] +pub(crate) async fn close( + db_instances: State<'_, DbInstances>, + db: Option, +) -> Result { + let instances = db_instances.0.read().await; + + let pools = if let Some(db) = db { + vec![db] + } else { + instances.keys().cloned().collect() + }; + + for pool in pools { + let db = instances.get(&pool).ok_or(Error::DatabaseNotLoaded(pool))?; + db.close().await; + } + + Ok(true) +} + +/// Execute a command against the database +#[command] +pub(crate) async fn execute( + db_instances: State<'_, DbInstances>, + db: String, + query: String, + values: Vec, +) -> Result<(u64, LastInsertId), crate::Error> { + let instances = db_instances.0.read().await; + + let db = instances.get(&db).ok_or(Error::DatabaseNotLoaded(db))?; + db.execute(query, values).await +} + +#[command] +pub(crate) async fn select( + db_instances: State<'_, DbInstances>, + db: String, + query: String, + values: Vec, +) -> Result>, crate::Error> { + let instances = db_instances.0.read().await; + + let db = instances.get(&db).ok_or(Error::DatabaseNotLoaded(db))?; + db.select(query, values).await +} diff --git a/plugins/sql/src/decode/mod.rs b/plugins/sql/src/decode/mod.rs new file mode 100644 index 0000000..0a2d2cd --- /dev/null +++ b/plugins/sql/src/decode/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[cfg(feature = "mysql")] +pub(crate) mod mysql; +#[cfg(feature = "postgres")] +pub(crate) mod postgres; +#[cfg(feature = "sqlite")] +pub(crate) mod sqlite; diff --git a/plugins/sql/src/decode/mysql.rs b/plugins/sql/src/decode/mysql.rs new file mode 100644 index 0000000..53fd20c --- /dev/null +++ b/plugins/sql/src/decode/mysql.rs @@ -0,0 +1,101 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde_json::Value as JsonValue; +use sqlx::{mysql::MySqlValueRef, TypeInfo, Value, ValueRef}; +use time::{Date, OffsetDateTime, PrimitiveDateTime, Time}; + +use crate::Error; + +pub(crate) fn to_json(v: MySqlValueRef) -> Result { + if v.is_null() { + return Ok(JsonValue::Null); + } + + let res = match v.type_info().name() { + "CHAR" | "VARCHAR" | "TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "ENUM" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode() { + JsonValue::String(v) + } else { + JsonValue::Null + } + } + "FLOAT" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::() { + JsonValue::from(v) + } else { + JsonValue::Null + } + } + "DOUBLE" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::() { + JsonValue::from(v) + } else { + JsonValue::Null + } + } + "TINYINT" | "SMALLINT" | "INT" | "MEDIUMINT" | "BIGINT" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::() { + JsonValue::Number(v.into()) + } else { + JsonValue::Null + } + } + "TINYINT UNSIGNED" | "SMALLINT UNSIGNED" | "INT UNSIGNED" | "MEDIUMINT UNSIGNED" + | "BIGINT UNSIGNED" | "YEAR" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::() { + JsonValue::Number(v.into()) + } else { + JsonValue::Null + } + } + "BOOLEAN" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode() { + JsonValue::Bool(v) + } else { + JsonValue::Null + } + } + "DATE" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::() { + JsonValue::String(v.to_string()) + } else { + JsonValue::Null + } + } + "TIME" => { + if let Ok(v) = ValueRef::to_owned(&v).try_decode::
+

Welcome to Tauri!

+ +
+ +

Click on the Tauri logo to learn more about the framework

+ + + +

+